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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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), ""))
|
||||
Reference in New Issue
Block a user