Harden RelayTV integration and prepare HACS 0.4.0 (#2)

* docs: add Home Assistant 0.4.0 hardening roadmap

* fix: protect media credentials and resume state

* feat: authenticate RelayTV API operations

* fix: make targeting and media state reliable

* test: add Home Assistant integration CI

* fix: validate API credentials during setup

* release: prepare HACS 0.4.0

* fix: align pytest dependency pin

* fix: close URL sanitizer gaps and correct player state reporting

Sync the sensitive-query-key list with the RelayTV server (adds auth,
exp, jwt, X-Emby-Token, X-Jellyfin-Token), filter query credentials
from relative URLs instead of returning them verbatim, and preserve
brackets around IPv6 literal hosts.

Also report volume on RelayTV's 0-100 scale unconditionally (a raw 1
is 1%, not full volume) and give the coordinator its own
position_updated_at stamp — the base DataUpdateCoordinator has no
last_update_success_time, so media_position_updated_at silently fell
back to now() on every read and the seek bar never extrapolated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:08:54 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 4faa1a1f34
commit f2bf565623
28 changed files with 1321 additions and 191 deletions
+7 -1
View File
@@ -11,6 +11,10 @@ This integration provides a RelayTV `media_player` entity, RelayTV service actio
- `status` events are treated as authoritative full snapshots
- `playback` / `queue` / `jellyfin` events trigger fast updates or targeted refreshes
- Sidebar panel is registered via Home Assistant frontend iframe panel APIs.
- Optional bearer-token authentication is validated during setup and supports
Home Assistant reauthentication.
- Credential-bearing query parameters are removed from entity state and
persisted resume keys.
- RelayTV services are registered from `services.yaml`:
- `smart_url`
- `play_now`
@@ -32,7 +36,8 @@ This integration provides a RelayTV `media_player` entity, RelayTV service actio
2. Restart Home Assistant.
3. Add integration: **Settings -> Devices & Services -> Add Integration -> RelayTV**.
4. Provide RelayTV base URL (example: `http://relaytv-host:8787`) and a server/display name.
4. Provide RelayTV base URL (example: `http://relaytv-host:8787`), a
server/display name, and the optional RelayTV API token.
## Options
@@ -51,5 +56,6 @@ This integration provides a RelayTV `media_player` entity, RelayTV service actio
- Snapshot requires active playback on the RelayTV server.
- Snapshot responses are normalized to absolute URLs for Home Assistant entity attributes.
- The integration keeps `/status` as bootstrap/fallback and does not treat `/ui/events` as a replay log.
- Version 0.4.0 requires Home Assistant 2026.7 or newer.
For fuller documentation and examples, see the repository root README.
+89 -35
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
import time
@@ -10,15 +11,16 @@ from urllib.parse import urlparse
from homeassistant.components import frontend
from homeassistant.components.media_source import async_resolve_media
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, ServiceValidationError
from homeassistant.helpers import aiohttp_client, config_validation as cv, entity_registry as er
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.storage import Store
from homeassistant.helpers.target import TargetSelection, async_extract_referenced_entity_ids
from .const import (
CONF_BASE_URL,
CONF_API_TOKEN,
CONF_PANEL_ENABLED,
CONF_PANEL_TARGET_ENTRY_ID,
CONF_RESUME_POSITIONS,
@@ -46,10 +48,12 @@ from .const import (
SERVICE_UPLOAD_MEDIA_PLAY,
)
from .coordinator import RelayTVCoordinator
from .relaytv_api import RelayTVApi
from .relaytv_api import RelayTVApi, RelayTVApiError, RelayTVAuthError
from .url_utils import canonical_media_key
_LOGGER = logging.getLogger(__name__)
RUNTIME_STORE_KEY = f"{DOMAIN}_runtime"
MAX_RESUME_POSITIONS = 500
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -112,7 +116,13 @@ async def _async_load_runtime_data(hass: HomeAssistant) -> dict:
data["runtime_store"] = Store(hass, 1, RUNTIME_STORE_KEY)
if "runtime_data" not in data:
data["runtime_data"] = await data["runtime_store"].async_load() or {CONF_RESUME_POSITIONS: {}}
original = data["runtime_data"].get(CONF_RESUME_POSITIONS, {})
migrated = _migrate_resume_positions(original)
data["runtime_data"][CONF_RESUME_POSITIONS] = migrated
if migrated != original:
await data["runtime_store"].async_save(data["runtime_data"])
data["runtime_data"].setdefault(CONF_RESUME_POSITIONS, {})
data.setdefault("runtime_lock", asyncio.Lock())
return data["runtime_data"]
@@ -121,6 +131,28 @@ async def _async_save_runtime_data(hass: HomeAssistant) -> None:
await data["runtime_store"].async_save(data["runtime_data"])
def _migrate_resume_positions(value: object) -> dict[str, float]:
"""Canonicalize, deduplicate, and cap persisted resume positions."""
if not isinstance(value, dict):
return {}
migrated: dict[str, float] = {}
for raw_url, raw_position in value.items():
key = canonical_media_key(raw_url)
if not key:
continue
try:
position = float(raw_position)
except (TypeError, ValueError):
continue
if position < 0:
continue
previous = migrated.pop(key, None)
migrated[key] = max(previous, position) if previous is not None else position
if len(migrated) > MAX_RESUME_POSITIONS:
migrated = dict(list(migrated.items())[-MAX_RESUME_POSITIONS:])
return migrated
def _get_entry_data(hass: HomeAssistant, entry_id: str) -> dict | None:
return hass.data.get(DOMAIN, {}).get(entry_id)
@@ -132,38 +164,21 @@ def _fallback_entry_id(hass: HomeAssistant) -> str | None:
return None
def _target_entity_ids_for_call(hass: HomeAssistant, call: ServiceCall) -> list[str]:
entity_ids: list[str] = []
raw_entity = call.data.get(CONF_ENTITY_ID)
if isinstance(raw_entity, str):
entity_ids.append(raw_entity)
elif isinstance(raw_entity, list):
entity_ids.extend(item for item in raw_entity if isinstance(item, str))
registry = er.async_get(hass)
device_id = call.data.get("device_id")
device_ids = [device_id] if isinstance(device_id, str) else device_id
if isinstance(device_ids, list):
for item in device_ids:
if not isinstance(item, str):
continue
for reg_entry in er.async_entries_for_device(registry, item):
if reg_entry.entity_id.startswith("media_player."):
entity_ids.append(reg_entry.entity_id)
return list(dict.fromkeys(entity_ids))
def _resolve_entry_ids_for_call(hass: HomeAssistant, call: ServiceCall) -> list[str]:
target = TargetSelection(call.data)
registry = er.async_get(hass)
entry_ids: list[str] = []
for entity_id in _target_entity_ids_for_call(hass, call):
selected = async_extract_referenced_entity_ids(hass, target)
entity_ids = selected.referenced | selected.indirectly_referenced
for entity_id in entity_ids:
reg_entry = registry.async_get(entity_id)
if reg_entry and _get_entry_data(hass, reg_entry.config_entry_id):
entry_ids.append(reg_entry.config_entry_id)
if entry_ids:
return list(dict.fromkeys(entry_ids))
if target.has_any_target:
raise ServiceValidationError("The selected target does not contain a loaded RelayTV media player")
panel_target = hass.data.get(DOMAIN, {}).get(DATA_PANEL_SETTINGS, {}).get(CONF_PANEL_TARGET_ENTRY_ID)
if panel_target and _get_entry_data(hass, panel_target):
@@ -242,15 +257,21 @@ def _async_unregister_panel(hass: HomeAssistant) -> None:
async def _async_update_panel(hass: HomeAssistant) -> None:
settings = await _async_ensure_settings(hass)
_async_unregister_panel(hass)
if not settings.get(CONF_PANEL_ENABLED, True):
_async_unregister_panel(hass)
return
target_entry_id = settings.get(CONF_PANEL_TARGET_ENTRY_ID)
target = _get_entry_data(hass, target_entry_id) if target_entry_id else None
if target is None:
configured_ids = {entry.entry_id for entry in hass.config_entries.async_entries(DOMAIN)}
if target_entry_id in configured_ids:
# Preserve both the selection and the existing panel while that
# entry is still loading.
return
fallback_id = _fallback_entry_id(hass)
if not fallback_id:
_async_unregister_panel(hass)
return
settings[CONF_PANEL_TARGET_ENTRY_ID] = fallback_id
await _async_save_settings(hass)
@@ -258,17 +279,20 @@ async def _async_update_panel(hass: HomeAssistant) -> None:
target_entry_id = fallback_id
if not target:
_async_unregister_panel(hass)
return
url = target[DATA_API].base_url
_async_unregister_panel(hass)
_register_panel(hass, path=DEFAULT_PANEL_PATH, title=DEFAULT_PANEL_TITLE, icon=DEFAULT_PANEL_ICON, url=url)
_LOGGER.info("Registered RelayTV panel to entry %s (%s)", target_entry_id, url)
async def _async_set_default_sidebar_target(hass: HomeAssistant, entry: ConfigEntry) -> None:
settings = await _async_ensure_settings(hass)
settings[CONF_PANEL_TARGET_ENTRY_ID] = entry.entry_id
await _async_save_settings(hass)
if not settings.get(CONF_PANEL_TARGET_ENTRY_ID):
settings[CONF_PANEL_TARGET_ENTRY_ID] = entry.entry_id
await _async_save_settings(hass)
def _entry_mappings(entry: ConfigEntry) -> list[dict]:
@@ -323,7 +347,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await _async_load_runtime_data(hass)
session = aiohttp_client.async_get_clientsession(hass)
api = RelayTVApi(session=session, base_url=base_url)
@callback
def _start_reauth() -> None:
entry.async_start_reauth_if_available(hass)
api = RelayTVApi(
session=session,
base_url=base_url,
api_token=str(entry.data.get(CONF_API_TOKEN) or ""),
)
try:
await api.validate()
except RelayTVAuthError as err:
raise ConfigEntryAuthFailed from err
except RelayTVApiError as err:
raise ConfigEntryNotReady(str(err)) from err
api.on_auth_failure = _start_reauth
coordinator = RelayTVCoordinator(hass=hass, api=api)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
DATA_API: api,
@@ -338,7 +377,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
url = status.get("url") or (status.get("now_playing") or {}).get("url")
position = status.get("position")
duration = status.get("duration")
if not isinstance(url, str) or not url:
key = canonical_media_key(url)
if not key:
return
try:
pos = float(position)
@@ -352,9 +392,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return
async def _save() -> None:
runtime = await _async_load_runtime_data(hass)
runtime[CONF_RESUME_POSITIONS][url] = pos
await _async_save_runtime_data(hass)
await _async_load_runtime_data(hass)
async with hass.data[DOMAIN]["runtime_lock"]:
runtime = hass.data[DOMAIN]["runtime_data"]
positions = runtime[CONF_RESUME_POSITIONS]
if dur and (pos / dur >= 0.98 or dur - pos <= 30.0):
if positions.pop(key, None) is not None:
await _async_save_runtime_data(hass)
return
previous = positions.get(key)
if previous is not None and abs(float(previous) - pos) < 10.0:
return
positions.pop(key, None)
positions[key] = pos
while len(positions) > MAX_RESUME_POSITIONS:
positions.pop(next(iter(positions)))
await _async_save_runtime_data(hass)
hass.async_create_task(_save())
@@ -459,7 +512,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not url:
return
runtime = await _async_load_runtime_data(hass)
resume_position = runtime.get(CONF_RESUME_POSITIONS, {}).get(url)
resume_position = runtime.get(CONF_RESUME_POSITIONS, {}).get(canonical_media_key(url))
for entry_id in _resolve_entry_ids_for_call(hass, call):
store = _get_entry_data(hass, entry_id)
if not store:
@@ -585,6 +638,7 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non
store = _get_entry_data(hass, entry.entry_id)
if store and base_url:
store[DATA_API].base_url = base_url
store[DATA_API].api_token = str(entry.data.get(CONF_API_TOKEN) or "")
await store[DATA_COORDINATOR].async_restart()
for unsub in store.get("mapping_unsubs", []):
unsub()
+142 -20
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlparse
import voluptuous as vol
@@ -9,11 +11,12 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.helpers import aiohttp_client, selector
from homeassistant.helpers.storage import Store
from .const import (
CONF_BASE_URL,
CONF_API_TOKEN,
CONF_PANEL_ENABLED,
CONF_PANEL_TARGET_ENTRY_ID,
CONF_SENSOR_STREAM_MAPPINGS,
@@ -23,6 +26,16 @@ from .const import (
DEFAULT_PANEL_TITLE,
DOMAIN,
)
from .relaytv_api import RelayTVApi, RelayTVAuthError, RelayTVConnectionError
_LOGGER = logging.getLogger(__name__)
_PASSWORD_SELECTOR = selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.PASSWORD,
autocomplete="current-password",
)
)
def _normalize_base_url(raw: str) -> str:
@@ -33,14 +46,46 @@ def _normalize_base_url(raw: str) -> str:
if "://" not in raw:
raw = f"http://{raw}"
parsed = urlparse(raw)
if not parsed.netloc:
return raw
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return ""
if parsed.username or parsed.password or parsed.query or parsed.fragment:
return ""
normalized = f"{parsed.scheme}://{parsed.netloc}{parsed.path}".rstrip("/")
if parsed.query:
normalized += f"?{parsed.query}"
return normalized
def _connection_schema(defaults: dict[str, Any] | None = None) -> vol.Schema:
values = defaults or {}
schema: dict[Any, Any] = {
vol.Required(CONF_BASE_URL, default=values.get(CONF_BASE_URL, "http://localhost:8787")): str,
vol.Required(CONF_SERVER_NAME, default=values.get(CONF_SERVER_NAME, DEFAULT_PANEL_TITLE)): str,
}
token = values.get(CONF_API_TOKEN)
marker = vol.Optional(CONF_API_TOKEN, default=token) if token else vol.Optional(CONF_API_TOKEN)
schema[marker] = _PASSWORD_SELECTOR
return vol.Schema(schema)
async def _async_validate_input(hass, data: dict[str, Any]) -> None:
session = aiohttp_client.async_get_clientsession(hass)
api = RelayTVApi(
session=session,
base_url=data[CONF_BASE_URL],
api_token=str(data.get(CONF_API_TOKEN) or ""),
)
await api.validate()
def _entry_data(user_input: dict[str, Any]) -> dict[str, Any]:
base_url = _normalize_base_url(user_input.get(CONF_BASE_URL, ""))
name = str(user_input.get(CONF_SERVER_NAME) or "").strip()
result: dict[str, Any] = {CONF_BASE_URL: base_url, CONF_NAME: name}
token = str(user_input.get(CONF_API_TOKEN) or "").strip()
if token:
result[CONF_API_TOKEN] = token
return result
class RelayTVWebUIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for RelayTV Web UI panel."""
@@ -50,28 +95,105 @@ class RelayTVWebUIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
base_url = _normalize_base_url(user_input.get(CONF_BASE_URL, ""))
name = (user_input.get(CONF_SERVER_NAME) or "").strip()
data = _entry_data(user_input)
base_url = data[CONF_BASE_URL]
name = data[CONF_NAME]
if not base_url:
errors["base"] = "missing_base_url"
errors["base"] = "invalid_base_url"
elif not name:
errors["base"] = "missing_name"
else:
await self.async_set_unique_id(base_url)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={CONF_BASE_URL: base_url, CONF_NAME: name},
)
try:
await _async_validate_input(self.hass, data)
except RelayTVAuthError:
errors["base"] = "invalid_auth"
except RelayTVConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error validating RelayTV")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(base_url)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=name, data=data)
schema = vol.Schema(
{
vol.Required(CONF_BASE_URL, default="http://localhost:8787"): str,
vol.Required(CONF_SERVER_NAME, default=DEFAULT_PANEL_TITLE): str,
}
return self.async_show_form(
step_id="user",
data_schema=_connection_schema(user_input),
errors=errors,
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_reauth(self, entry_data):
"""Start reauthentication after a rejected write request."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(self, user_input=None):
"""Validate and save a replacement API token."""
errors = {}
entry = self._get_reauth_entry()
if user_input is not None:
data = dict(entry.data)
token = str(user_input.get(CONF_API_TOKEN) or "").strip()
if token:
data[CONF_API_TOKEN] = token
else:
data.pop(CONF_API_TOKEN, None)
try:
await _async_validate_input(self.hass, data)
except RelayTVAuthError:
errors["base"] = "invalid_auth"
except RelayTVConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error reauthenticating RelayTV")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(entry, data=data)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): _PASSWORD_SELECTOR}),
errors=errors,
)
async def async_step_reconfigure(self, user_input=None):
"""Update server connection details."""
errors = {}
entry = self._get_reconfigure_entry()
defaults = {
CONF_BASE_URL: entry.data.get(CONF_BASE_URL, ""),
CONF_SERVER_NAME: entry.data.get(CONF_NAME, entry.title),
CONF_API_TOKEN: entry.data.get(CONF_API_TOKEN, ""),
}
if user_input is not None:
data = _entry_data(user_input)
if not data[CONF_BASE_URL]:
errors["base"] = "invalid_base_url"
elif not data[CONF_NAME]:
errors["base"] = "missing_name"
else:
try:
await _async_validate_input(self.hass, data)
except RelayTVAuthError:
errors["base"] = "invalid_auth"
except RelayTVConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error reconfiguring RelayTV")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
entry,
unique_id=data[CONF_BASE_URL],
title=data[CONF_NAME],
data=data,
)
return self.async_show_form(
step_id="reconfigure",
data_schema=_connection_schema(user_input or defaults),
errors=errors,
)
@staticmethod
@callback
+1
View File
@@ -5,6 +5,7 @@ DOMAIN = "relaytv"
PLATFORMS: list[str] = ["media_player"]
CONF_BASE_URL = "base_url"
CONF_API_TOKEN = "api_token"
CONF_SERVER_NAME = "server_name"
CONF_PANEL_TITLE = "panel_title"
CONF_PANEL_ICON = "panel_icon"
+47 -9
View File
@@ -5,15 +5,17 @@ from __future__ import annotations
import asyncio
import json
import logging
from datetime import timedelta
from datetime import datetime, timedelta
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .relaytv_api import RelayTVApi
from .relaytv_api import RelayTVApi, RelayTVApiError, RelayTVAuthError
_LOGGER = logging.getLogger(__name__)
@@ -21,6 +23,7 @@ _POLL_INTERVAL_FALLBACK = timedelta(seconds=3)
_SSE_CONNECT_TIMEOUT = 10
_SSE_READ_TIMEOUT = 90
_SSE_REFRESH_DEBOUNCE_SEC = 0.25
_POSITION_UPDATE_BUCKET_SEC = 15
def _as_float(value: Any) -> float | None:
@@ -39,6 +42,15 @@ def _rounded_int(value: Any) -> int | None:
return int(round(num))
def _position_bucket(data: dict[str, Any]) -> int | None:
position = _as_float(data.get("position"))
if position is None:
return None
if data.get("playing") and not data.get("paused"):
return int(position // _POSITION_UPDATE_BUCKET_SEC)
return int(round(position))
def _extract_media_fields(data: dict[str, Any]) -> tuple[str, str, str]:
now_playing = data.get("now_playing")
np = now_playing if isinstance(now_playing, dict) else {}
@@ -70,16 +82,18 @@ def _material_state_view(data: dict[str, Any] | None) -> tuple[Any, ...] | None:
has_now_playing = data.get("has_now_playing")
if has_now_playing is None:
has_now_playing = bool(url or title)
playing = bool(data.get("playing"))
return (
str(data.get("state") or ""),
bool(data.get("playing")),
playing,
bool(data.get("paused")),
int(data.get("queue_length") or 0),
bool(has_now_playing),
_rounded_int(data.get("duration")),
_rounded_int(data.get("volume")),
None if data.get("mute") is None else bool(data.get("mute")),
_position_bucket(data),
_rounded_int(data.get("volume")) if playing else None,
(None if data.get("mute") is None else bool(data.get("mute"))) if playing else None,
title,
url,
thumbnail,
@@ -93,6 +107,7 @@ def _apply_if_material_change(
"""Update coordinator data only when HA-visible state materially changed."""
if _material_state_view(coordinator.data) == _material_state_view(payload):
return False
coordinator.note_position_update(payload)
coordinator.async_set_updated_data(payload)
return True
@@ -121,12 +136,34 @@ class RelayTVCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._sse_task: asyncio.Task[None] | None = None
self._refresh_task: asyncio.Task[None] | None = None
self._sse_enabled = False
self.position_updated_at: datetime | None = None
self._position_signature: tuple[Any, ...] | None = None
def note_position_update(self, data: dict[str, Any] | None) -> None:
"""Record when the reported playback position last changed.
async_set_updated_data never stamps last_update_success_time, so the
coordinator keeps its own timestamp for media_position_updated_at.
"""
payload = data if isinstance(data, dict) else {}
signature = (
_as_float(payload.get("position")),
bool(payload.get("playing")),
bool(payload.get("paused")),
)
if signature != self._position_signature:
self._position_signature = signature
self.position_updated_at = dt_util.utcnow()
async def _async_update_data(self) -> dict[str, Any]:
data = await self.api.get_status()
if data is None:
raise UpdateFailed("Unable to fetch RelayTV status")
return data
try:
status = await self.api.get_status()
except RelayTVAuthError as err:
raise ConfigEntryAuthFailed from err
except RelayTVApiError as err:
raise UpdateFailed(str(err)) from err
self.note_position_update(status)
return status
async def async_start(self) -> None:
"""Start the background SSE listener."""
@@ -232,6 +269,7 @@ class RelayTVCoordinator(DataUpdateCoordinator[dict[str, Any]]):
headers = {
"Accept": "text/event-stream",
"Cache-Control": "no-cache",
**self.api.auth_headers,
}
while True:
+1 -1
View File
@@ -12,5 +12,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/mcgeezy/relaytv-ha/issues",
"requirements": [],
"version": "0.3.12"
"version": "0.4.0"
}
+32 -29
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import datetime
from urllib.parse import urlparse
from typing import Any, Optional
@@ -15,10 +15,12 @@ from homeassistant.components.media_player.const import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DATA_API, DATA_COORDINATOR, DATA_LAST_SNAPSHOT_URL, DOMAIN
from .url_utils import sanitize_url
def _num(v: Any) -> Optional[float]:
@@ -30,6 +32,13 @@ def _num(v: Any) -> Optional[float]:
return None
def _first_present(data: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in data and data[key] is not None:
return data[key]
return None
def _abs_url(base: str, maybe: Optional[str]) -> Optional[str]:
if not maybe:
return None
@@ -70,8 +79,9 @@ def _parse_status(data: Optional[dict[str, Any]]) -> _StatusView:
vol = data.get("volume")
vol_f = _num(vol)
# Some APIs use 0-100
if vol_f is not None and vol_f > 1.0:
# RelayTV always reports volume on a 0-100 scale (a raw 1 means 1%,
# not full volume), so convert unconditionally.
if vol_f is not None:
vol_f = max(0.0, min(1.0, vol_f / 100.0))
muted = data.get("muted")
@@ -79,8 +89,8 @@ def _parse_status(data: Optional[dict[str, Any]]) -> _StatusView:
muted = data.get("mute")
muted_b = None if muted is None else bool(muted)
position = _num(data.get("position") or data.get("pos") or data.get("time"))
duration = _num(data.get("duration") or data.get("len") or data.get("total"))
position = _num(_first_present(data, "position", "pos", "time"))
duration = _num(_first_present(data, "duration", "len", "total"))
np = data.get("now_playing") or data.get("media") or {}
title = None
@@ -145,7 +155,13 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
self._entry = entry
self._api = api
self._attr_unique_id = f"{entry.entry_id}_player"
self._attr_name = entry.data.get(CONF_NAME, entry.title)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=entry.data.get(CONF_NAME, entry.title),
manufacturer="RelayTV",
model="RelayTV server",
configuration_url=api.base_url,
)
self._attr_supported_features = (
MediaPlayerEntityFeature.PLAY
@@ -155,6 +171,7 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
)
@@ -179,20 +196,11 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
def volume_level(self) -> Optional[float]:
# HA expects 0.0-1.0. RelayTV reports 0-100 (or None when closed).
v = _parse_status(self.coordinator.data).volume
try:
if v is None:
return 0.0
vf = float(v)
if vf > 1.0:
vf = vf / 100.0
return max(0.0, min(1.0, vf))
except Exception:
return 0.0
return v
@property
def is_volume_muted(self) -> Optional[bool]:
# RelayTV doesn't currently expose mute as a dedicated API.
return None
return _parse_status(self.coordinator.data).muted
@property
def media_title(self) -> Optional[str]:
@@ -200,7 +208,7 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
@property
def media_content_id(self) -> Optional[str]:
return _parse_status(self.coordinator.data).url
return sanitize_url(_parse_status(self.coordinator.data).url) or None
@property
def media_duration(self) -> Optional[float]:
@@ -212,15 +220,10 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
@property
def media_position_updated_at(self) -> Optional[datetime]:
# Helps HA render a moving seek bar while playing.
# Use coordinator timestamp if available; otherwise fall back to "now" (UTC).
t = getattr(self.coordinator, "last_update_success_time", None)
if t is None:
return datetime.now(timezone.utc)
# Ensure timezone-aware
if t.tzinfo is None:
return t.replace(tzinfo=timezone.utc)
return t
# The coordinator stamps this whenever the reported position changes
# (SSE and poll paths both), letting HA extrapolate the seek bar
# between updates.
return self.coordinator.position_updated_at
@property
@@ -266,8 +269,8 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
await self.coordinator.async_request_refresh()
async def async_mute_volume(self, mute: bool) -> None:
# Not supported by RelayTV API at this time.
return
await self._api.mute(mute)
await self.coordinator.async_request_refresh()
async def async_turn_on(self) -> None:
# Same behavior as PLAY.
+120 -47
View File
@@ -28,14 +28,41 @@ import logging
import mimetypes
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from typing import Any, Callable, Optional
import aiohttp
from homeassistant.exceptions import HomeAssistantError
_LOGGER = logging.getLogger(__name__)
_UPLOAD_CHUNK_SIZE = 1024 * 1024
class RelayTVApiError(HomeAssistantError):
"""Base error returned by the RelayTV API."""
class RelayTVConnectionError(RelayTVApiError):
"""RelayTV could not be reached."""
class RelayTVAuthError(RelayTVApiError):
"""RelayTV rejected the configured API token."""
class RelayTVResponseError(RelayTVApiError):
"""RelayTV returned an unsuccessful response."""
def __init__(self, status: int, detail: str) -> None:
super().__init__(f"RelayTV request failed ({status}): {detail}")
self.status = status
self.detail = detail
class RelayTVEndpointNotFound(RelayTVResponseError):
"""RelayTV does not provide a compatibility endpoint."""
def _join(base: str, path: str) -> str:
base = (base or "").rstrip("/")
path = (path or "").lstrip("/")
@@ -60,32 +87,58 @@ class RelayTVApi:
session: aiohttp.ClientSession
base_url: str
api_token: str = ""
timeout_s: float = 8.0
upload_timeout_s: float = 3600.0
on_auth_failure: Callable[[], None] | None = None
def url_for(self, path: str) -> str:
"""Build an absolute RelayTV URL for a relative API path."""
return _join(self.base_url, path)
@property
def auth_headers(self) -> dict[str, str]:
"""Return bearer authentication headers when a token is configured."""
token = str(self.api_token or "").strip()
return {"Authorization": f"Bearer {token}"} if token else {}
async def _raise_response_error(self, resp: aiohttp.ClientResponse) -> None:
try:
payload = await resp.json(content_type=None)
detail = payload.get("detail") if isinstance(payload, dict) else payload
except Exception:
detail = await resp.text()
message = str(detail or resp.reason or "request failed")[:500]
if resp.status in (401, 403):
if self.on_auth_failure is not None:
self.on_auth_failure()
raise RelayTVAuthError("RelayTV rejected the configured API token")
if resp.status in (404, 405):
raise RelayTVEndpointNotFound(resp.status, message)
raise RelayTVResponseError(resp.status, message)
async def _request_json(
self,
method: str,
path: str,
*,
json: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
) -> dict[str, Any]:
url = self.url_for(path)
try:
async with asyncio.timeout(self.timeout_s):
async with self.session.request(method, url, json=json) as resp:
async with self.session.request(method, url, json=json, headers=self.auth_headers) as resp:
if resp.status >= 400:
return None
await self._raise_response_error(resp)
try:
return await resp.json(content_type=None)
payload = await resp.json(content_type=None)
return payload if isinstance(payload, dict) else {"result": payload}
except Exception:
return {}
except Exception:
return None
except RelayTVApiError:
raise
except (TimeoutError, aiohttp.ClientError) as err:
raise RelayTVConnectionError(f"Unable to communicate with RelayTV at {self.base_url}") from err
async def _upload_media(
self,
@@ -105,26 +158,37 @@ class RelayTVApi:
try:
form.add_field("file", _iter_file_chunks(file_path), filename=filename, content_type=content_type)
async with asyncio.timeout(self.upload_timeout_s):
async with self.session.post(url, data=form) as resp:
async with self.session.post(url, data=form, headers=self.auth_headers) as resp:
if resp.status >= 400:
_LOGGER.debug("RelayTV media upload failed: %s %s", resp.status, await resp.text())
return None
await self._raise_response_error(resp)
try:
return await resp.json(content_type=None)
payload = await resp.json(content_type=None)
return payload if isinstance(payload, dict) else {"result": payload}
except Exception:
return {}
except Exception:
_LOGGER.debug("RelayTV media upload request failed", exc_info=True)
return None
except RelayTVApiError:
raise
except (TimeoutError, aiohttp.ClientError) as err:
raise RelayTVConnectionError(f"Unable to upload media to RelayTV at {self.base_url}") from err
async def get_status(self) -> Optional[dict[str, Any]]:
async def get_status(self) -> dict[str, Any]:
"""Fetch current playback/status."""
return await self._request_json("GET", "status")
async def validate(self) -> None:
"""Validate connectivity and write authentication without changing state."""
await self.get_status()
try:
await self._request_json("POST", "auth/check", json={})
except RelayTVEndpointNotFound:
# Servers predating /auth/check are valid when their write guard is
# disabled. Protected older servers reject this before route lookup.
return
async def smart_url(self, url: str) -> bool:
"""RelayTV one-button behavior (POST /smart)."""
data = await self._request_json("POST", "smart", json={"url": url})
return data is not None
await self._request_json("POST", "smart", json={"url": url})
return True
async def play(self, url: str, *, use_ytdlp: bool | None = None, cec: bool | None = None) -> bool:
"""Immediate play; clears queue (POST /play)."""
@@ -133,13 +197,13 @@ class RelayTVApi:
payload["use_ytdlp"] = bool(use_ytdlp)
if cec is not None:
payload["cec"] = bool(cec)
data = await self._request_json("POST", "play", json=payload)
return data is not None
await self._request_json("POST", "play", json=payload)
return True
async def enqueue(self, url: str) -> bool:
"""Add an item to the end of the queue (POST /enqueue)."""
data = await self._request_json("POST", "enqueue", json={"url": url})
return data is not None
await self._request_json("POST", "enqueue", json={"url": url})
return True
async def upload_media(self, path: str, *, title: str | None = None) -> Optional[dict[str, Any]]:
"""Upload local media without queueing or playing (POST /ingest/media)."""
@@ -167,7 +231,8 @@ class RelayTVApi:
payload["timeout_sec"] = float(timeout_sec)
if volume_override is not None:
payload["volume_override"] = float(volume_override)
return (await self._request_json("POST", "play_temporary", json=payload)) is not None
await self._request_json("POST", "play_temporary", json=payload)
return True
async def overlay(
self,
@@ -186,39 +251,45 @@ class RelayTVApi:
payload["position"] = position
if image_url:
payload["image_url"] = image_url
return (await self._request_json("POST", "overlay", json=payload)) is not None
await self._request_json("POST", "overlay", json=payload)
return True
async def play_at(self, *, url: str, start_at: float) -> bool:
payload = {"url": url, "start_at": float(start_at)}
return (await self._request_json("POST", "play_at", json=payload)) is not None
await self._request_json("POST", "play_at", json=payload)
return True
async def snapshot(self) -> Optional[dict[str, Any]]:
data = await self._request_json("POST", "snapshot", json={})
if data is not None:
return data
return await self._request_json("GET", "snapshot")
try:
return await self._request_json("POST", "snapshot", json={})
except RelayTVEndpointNotFound:
return await self._request_json("GET", "snapshot")
async def next(self) -> bool:
"""Skip to the next queued item (POST /next)."""
data = await self._request_json("POST", "next", json={})
return data is not None
await self._request_json("POST", "next", json={})
return True
async def previous(self) -> bool:
"""Go to the previous item (POST /previous)."""
data = await self._request_json("POST", "previous", json={})
return data is not None
await self._request_json("POST", "previous", json={})
return True
async def pause(self) -> bool:
return (await self._request_json("POST", "pause", json={})) is not None
await self._request_json("POST", "pause", json={})
return True
async def resume(self) -> bool:
return (await self._request_json("POST", "resume", json={})) is not None
await self._request_json("POST", "resume", json={})
return True
async def toggle_pause(self) -> bool:
return (await self._request_json("POST", "toggle_pause", json={})) is not None
await self._request_json("POST", "toggle_pause", json={})
return True
async def stop(self) -> bool:
return (await self._request_json("POST", "stop", json={})) is not None
await self._request_json("POST", "stop", json={})
return True
async def playback_play(self) -> bool:
"""User-facing Play semantics (POST /playback/play).
@@ -230,10 +301,11 @@ class RelayTVApi:
If the endpoint is missing (older servers), fall back to ensure_playing().
"""
data = await self._request_json("POST", "playback/play", json={})
if data is not None:
try:
await self._request_json("POST", "playback/play", json={})
return True
return await self.ensure_playing()
except RelayTVEndpointNotFound:
return await self.ensure_playing()
async def seek_abs(self, sec: float) -> bool:
"""Seek to an absolute position in seconds (POST /seek_abs)."""
@@ -241,8 +313,8 @@ class RelayTVApi:
sec_f = float(sec)
except Exception:
return False
data = await self._request_json("POST", "seek_abs", json={"sec": sec_f})
return data is not None
await self._request_json("POST", "seek_abs", json={"sec": sec_f})
return True
async def set_volume(self, level: Any) -> bool:
"""Set volume from HA's 0.0-1.0 slider to RelayTV's 0-100 scale."""
@@ -258,12 +330,13 @@ class RelayTVApi:
pct = v
pct = max(0.0, min(200.0, float(pct)))
# RelayTV expects {"set": <number>}
for val in (pct, round(pct), int(round(pct))):
data = await self._request_json("POST", "volume", json={"set": val})
if data is not None:
return True
return False
await self._request_json("POST", "volume", json={"set": pct})
return True
async def mute(self, muted: bool) -> bool:
"""Set RelayTV's native mpv mute property."""
await self._request_json("POST", "mute", json={"set": bool(muted)})
return True
async def ensure_playing(self) -> bool:
"""Best-effort play semantics for Home Assistant.
+22 -2
View File
@@ -7,13 +7,33 @@
"description": "Add a RelayTV server reachable from Home Assistant.",
"data": {
"base_url": "RelayTV server address",
"server_name": "Name"
"server_name": "Name",
"api_token": "API token (optional)"
}
},
"reauth_confirm": {
"title": "Update RelayTV authentication",
"description": "Enter the API token configured on the RelayTV server.",
"data": {
"api_token": "API token"
}
},
"reconfigure": {
"title": "Reconfigure RelayTV",
"description": "Update the RelayTV connection and authentication settings.",
"data": {
"base_url": "RelayTV server address",
"server_name": "Name",
"api_token": "API token (optional)"
}
}
},
"error": {
"missing_base_url": "Please enter a RelayTV base URL.",
"invalid_base_url": "Enter a valid HTTP or HTTPS RelayTV base URL without credentials, query parameters, or a fragment.",
"missing_name": "Please enter a name.",
"cannot_connect": "Unable to connect to RelayTV.",
"invalid_auth": "The RelayTV API token was rejected.",
"unknown": "Unexpected error while connecting to RelayTV.",
"already_configured": "This RelayTV server is already configured."
}
},
+22 -2
View File
@@ -7,13 +7,33 @@
"description": "Add a RelayTV server reachable from Home Assistant.",
"data": {
"base_url": "RelayTV server address",
"server_name": "Name"
"server_name": "Name",
"api_token": "API token (optional)"
}
},
"reauth_confirm": {
"title": "Update RelayTV authentication",
"description": "Enter the API token configured on the RelayTV server.",
"data": {
"api_token": "API token"
}
},
"reconfigure": {
"title": "Reconfigure RelayTV",
"description": "Update the RelayTV connection and authentication settings.",
"data": {
"base_url": "RelayTV server address",
"server_name": "Name",
"api_token": "API token (optional)"
}
}
},
"error": {
"missing_base_url": "Please enter a RelayTV base URL.",
"invalid_base_url": "Enter a valid HTTP or HTTPS RelayTV base URL without credentials, query parameters, or a fragment.",
"missing_name": "Please enter a name.",
"cannot_connect": "Unable to connect to RelayTV.",
"invalid_auth": "The RelayTV API token was rejected.",
"unknown": "Unexpected error while connecting to RelayTV.",
"already_configured": "This RelayTV server is already configured."
}
},
+82
View File
@@ -0,0 +1,82 @@
"""URL safety and stable media-key helpers."""
from __future__ import annotations
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
# Keep in sync with _SENSITIVE_QUERY_KEYS in the RelayTV server's
# public_media.py; both sides must redact the same credential parameters.
_SENSITIVE_QUERY_KEYS = {
"access_token",
"apikey",
"api_key",
"auth",
"authorization",
"auth_token",
"cookie",
"exp",
"expires",
"hdnea",
"hdnts",
"jwt",
"key-pair-id",
"policy",
"sig",
"signature",
"token",
"x-emby-token",
"x-jellyfin-token",
}
def _is_sensitive_query_key(key: str) -> bool:
normalized = str(key or "").strip().lower()
return normalized in _SENSITIVE_QUERY_KEYS or normalized.startswith("x-amz-")
def _filtered_query(query: str) -> str:
pairs = [
(key, val)
for key, val in parse_qsl(query, keep_blank_values=True)
if not _is_sensitive_query_key(key)
]
return urlencode(pairs, doseq=True)
def sanitize_url(value: object) -> str:
"""Remove credentials, fragments, and transient signing parameters."""
raw = str(value or "").strip()
if not raw:
return ""
try:
parsed = urlsplit(raw)
except Exception:
return ""
if not parsed.scheme or not parsed.netloc:
# Relative identifiers from older servers can still carry
# credentials in their query string.
return urlunsplit((parsed.scheme, "", parsed.path, _filtered_query(parsed.query), ""))
hostname = (parsed.hostname or "").lower()
if not hostname:
return ""
netloc = f"[{hostname}]" if ":" in hostname else hostname
if parsed.port is not None:
netloc = f"{netloc}:{parsed.port}"
return urlunsplit((parsed.scheme.lower(), netloc, parsed.path, _filtered_query(parsed.query), ""))
def canonical_media_key(value: object) -> str:
"""Return a deterministic, credential-free key for resume state."""
safe = sanitize_url(value)
if not safe:
return ""
try:
parsed = urlsplit(safe)
except Exception:
return safe
if not parsed.scheme or not parsed.netloc:
return safe
query = sorted(parse_qsl(parsed.query, keep_blank_values=True))
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query, doseq=True), ""))