Files
markandClaude Fable 5 f2bf565623 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>
2026-07-19 21:08:54 -05:00

83 lines
2.3 KiB
Python

"""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), ""))