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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user