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:
@@ -0,0 +1,31 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements_test.txt
|
||||
|
||||
- name: Install test dependencies
|
||||
run: python -m pip install --requirement requirements_test.txt
|
||||
|
||||
- name: Ruff
|
||||
run: ruff check custom_components tests
|
||||
|
||||
- name: Pytest
|
||||
run: python -m pytest -q
|
||||
+27
-2
@@ -1,10 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- Remove credentials from media-player state and stored resume keys, migrate
|
||||
legacy resume data, and cap the resume store at 500 entries.
|
||||
- Add optional RelayTV bearer-token configuration, setup validation,
|
||||
reauthentication, and reconfiguration flows.
|
||||
- Report connection, authentication, and server errors instead of silently
|
||||
treating failed actions as successful.
|
||||
- Prevent an invalid explicit service target from falling back to a different
|
||||
RelayTV server and preserve the selected sidebar target during startup.
|
||||
- Add native mute support, accurate zero-valued media properties, stable
|
||||
Home Assistant device metadata, and quieter idle-state event handling.
|
||||
- Add a pinned Home Assistant 2026.7 test environment and GitHub Actions CI.
|
||||
|
||||
Security note: older Home Assistant recorder rows can retain media identifiers
|
||||
written by an earlier integration release. Rotate any affected upstream media
|
||||
API credentials and purge the RelayTV entity's old recorder history if those
|
||||
identifiers contained secrets.
|
||||
|
||||
## 0.3.12
|
||||
|
||||
- Fix the HACS release archive layout so `manifest.json` installs directly
|
||||
under `/config/custom_components/relaytv/`.
|
||||
|
||||
## 0.2.3
|
||||
|
||||
- Fix HA seek bar by providing media_position_updated_at.
|
||||
- Add entity_picture support (thumbnail) when RelayTV status includes artwork fields.
|
||||
- Improve seek/volume command payload compatibility.
|
||||
|
||||
# Changelog
|
||||
|
||||
## 0.2.2
|
||||
- Fix `relaytv.smart_url` behavior: prefer enqueue/smart endpoints and avoid replacing current playback when possible.
|
||||
## v0.2.1
|
||||
|
||||
@@ -24,6 +24,7 @@ RelayTV integrates with Home Assistant as a local `media_player` plus RelayTV-sp
|
||||
- Creates a `media_player` entity for each RelayTV config entry
|
||||
- Supports multiple RelayTV servers
|
||||
- Uses RelayTV live event updates plus `/status` refresh fallback
|
||||
- Supports an optional RelayTV API bearer token with reauthentication
|
||||
- Adds a Home Assistant sidebar panel for the RelayTV web UI
|
||||
- Exposes RelayTV-specific services for smart play, temporary playback, overlays, snapshots, synchronized playback, upload/play, upload/enqueue, and resume behavior
|
||||
- Supports automation-friendly control from scripts, dashboards, and mobile workflows
|
||||
@@ -37,6 +38,7 @@ RelayTV integrates with Home Assistant as a local `media_player` plus RelayTV-sp
|
||||
- Previous
|
||||
- Seek
|
||||
- Set volume
|
||||
- Mute and unmute
|
||||
- Turn on
|
||||
- Turn off
|
||||
|
||||
@@ -122,6 +124,11 @@ variables:
|
||||
5. Enter:
|
||||
- RelayTV base URL, for example `http://relaytv-host:8787`
|
||||
- Display name for this RelayTV instance
|
||||
- RelayTV API token, if `RELAYTV_API_TOKEN` is enabled on the server
|
||||
|
||||
Use **Reconfigure** on the integration entry to change the server address,
|
||||
display name, or API token. The token is stored in the Home Assistant config
|
||||
entry and is sent only as a bearer authorization header to RelayTV.
|
||||
|
||||
---
|
||||
|
||||
@@ -133,6 +140,22 @@ From the integration options flow, you can configure:
|
||||
- `panel_target_entry_id` — choose which RelayTV server is used by the shared sidebar panel
|
||||
- `sensor_stream_mappings` — map sensors to temporary playback URLs
|
||||
|
||||
## Security and Upgrading to 0.4.0
|
||||
|
||||
Version 0.4.0 sanitizes credential-bearing playback URLs before they reach
|
||||
Home Assistant state or resume storage. Existing resume data is migrated and
|
||||
limited to the 500 most recent media keys automatically.
|
||||
|
||||
Home Assistant recorder history written by an older version is not rewritten.
|
||||
If a previous media identifier contained an upstream Jellyfin/Emby API key or
|
||||
signed URL, rotate that credential and consider purging old history for the
|
||||
RelayTV media-player entity.
|
||||
|
||||
For defense in depth, update the RelayTV server to a version that redacts
|
||||
private playback fields from public status, queue, history, and SSE payloads.
|
||||
The matching server update also allows Home Assistant to process RelayTV
|
||||
thumbnails across origins without browser CORS errors.
|
||||
|
||||
---
|
||||
|
||||
## Example Service Calls
|
||||
@@ -220,6 +243,9 @@ When using the service UI, the `file` field can also select a local Home Assista
|
||||
- Overlay calls must include at least `text` or `image_url`
|
||||
- Snapshots require active playback on the RelayTV server
|
||||
- `/ui/events` is treated as a live push stream, not a replay log; `/status` remains the reconnect/bootstrap fallback
|
||||
- RelayTV releases predating `POST /auth/check` remain compatible, but the
|
||||
current server release is recommended for public-payload redaction and
|
||||
cross-origin thumbnails
|
||||
|
||||
---
|
||||
|
||||
@@ -247,7 +273,16 @@ https://buymeacoffee.com/relaytv
|
||||
|
||||
## Compatibility
|
||||
|
||||
Validated against the RelayTV server API and current app route structure.
|
||||
Version 0.4.0 requires Home Assistant 2026.7 or newer. It is automatically
|
||||
tested against Home Assistant 2026.7.2 and the current RelayTV server API.
|
||||
|
||||
For local verification:
|
||||
|
||||
```bash
|
||||
python -m pip install --requirement requirements_test.txt
|
||||
ruff check custom_components tests
|
||||
python -m pytest -q
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+38
-41
@@ -1,50 +1,47 @@
|
||||
# RelayTV HA 0.3.12
|
||||
# RelayTV HA 0.4.0
|
||||
|
||||
## HACS Install Layout Fix
|
||||
This release hardens the Home Assistant integration for credential safety,
|
||||
authenticated RelayTV servers, and dependable multi-server control. It
|
||||
requires Home Assistant 2026.7 or newer.
|
||||
|
||||
This release fixes the HACS `relaytv.zip` package layout. Version `0.3.11` packaged the integration inside an extra top-level `relaytv/` directory, which could install as:
|
||||
## Highlights
|
||||
|
||||
```text
|
||||
/config/custom_components/relaytv/relaytv/manifest.json
|
||||
```
|
||||
- Playback URLs are sanitized before being exposed as media-player state or
|
||||
used as persisted resume keys.
|
||||
- Legacy resume data is migrated automatically, deduplicated, and capped at
|
||||
500 entries.
|
||||
- Optional `RELAYTV_API_TOKEN` bearer authentication can be configured with a
|
||||
masked field and recovered through Home Assistant reauthentication.
|
||||
- RelayTV API failures now surface as real Home Assistant action errors.
|
||||
- Explicit service targets can no longer fall back to another TV.
|
||||
- Sidebar target selection survives multi-entry startup ordering.
|
||||
- The media player adds native mute, correct zero-valued position/duration,
|
||||
and a stable Home Assistant device with a configuration link.
|
||||
- Automated coverage now includes configuration flows, setup authentication,
|
||||
URL/storage migration, targeting, panel persistence, API failures,
|
||||
coordinator deduplication, and media properties.
|
||||
|
||||
Home Assistant expects:
|
||||
## Security migration
|
||||
|
||||
```text
|
||||
/config/custom_components/relaytv/manifest.json
|
||||
```
|
||||
The integration migrates its own resume store on first startup. Home Assistant
|
||||
recorder history from an older release is intentionally left untouched. If an
|
||||
old media identifier included a Jellyfin/Emby API key, bearer token, or signed
|
||||
stream credential:
|
||||
|
||||
Updating to `0.3.12` restores the correct flat zip layout for HACS installs and updates. Restart Home Assistant after updating.
|
||||
1. Rotate the affected upstream credential.
|
||||
2. Purge old recorder history for the RelayTV media-player entity if required
|
||||
by your security policy.
|
||||
3. Update the RelayTV server to the companion release that redacts private
|
||||
playback fields from public status, SSE, queue, and history responses.
|
||||
|
||||
## Companion App Share Automation
|
||||
The companion server update also adds `POST /auth/check` for non-mutating token
|
||||
validation and cross-origin headers for Home Assistant thumbnail rendering.
|
||||
Older unprotected RelayTV servers remain compatible, but the current server is
|
||||
strongly recommended.
|
||||
|
||||
This release continues to highlight the Home Assistant Companion App share automation flow. You can share URLs or text from the Home Assistant mobile app and send them directly to RelayTV with `relaytv.smart_url`.
|
||||
## Upgrade
|
||||
|
||||
```yaml
|
||||
alias: RelayTV - Smart play from share
|
||||
description: Opens shared URLs/text in RelayTV via relaytv.smart_url
|
||||
triggers:
|
||||
- event_type: mobile_app.share
|
||||
trigger: event
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ shared | trim | length > 0 }}"
|
||||
actions:
|
||||
- data:
|
||||
url: "{{ shared | trim }}"
|
||||
action: relaytv.smart_url
|
||||
mode: single
|
||||
variables:
|
||||
shared: |-
|
||||
{{ trigger.event.data.url
|
||||
if trigger.event.data.url is defined else
|
||||
(trigger.event.data.text if trigger.event.data.text is defined else '') }}
|
||||
```
|
||||
|
||||
## HACS Display Assets
|
||||
|
||||
- Keeps integration brand assets under `custom_components/relaytv/brand/`.
|
||||
- Keeps root-level `icon.png` and `logo.png` fallbacks for repository/HACS renderers that look at the repository root.
|
||||
- Uses the README screenshot hosted from GitHub raw content for reliable rendering in HACS.
|
||||
|
||||
Note: HACS' integration list icon is ultimately loaded from `brands.home-assistant.io` for integration domains. If the RelayTV icon still appears generic in the list, the remaining durable step is submitting `relaytv` to the Home Assistant Brands repository.
|
||||
Install 0.4.0 through HACS, restart Home Assistant, and use **Reconfigure** on
|
||||
the RelayTV integration entry if the server has `RELAYTV_API_TOKEN` enabled.
|
||||
The HACS archive remains flat, with `manifest.json` directly at the archive
|
||||
root as required for `/config/custom_components/relaytv/` installation.
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# RelayTV Home Assistant 0.4.0 Roadmap
|
||||
|
||||
Status: implemented and verified on `feat/ha-hardening-v0.4.0`. The companion
|
||||
RelayTV server changes are isolated on `fix/redact-public-playback-urls`.
|
||||
|
||||
This roadmap turns the July 2026 integration review into a coordinated Home
|
||||
Assistant release and a small RelayTV server companion change. Each milestone
|
||||
is independently testable and committed before the next milestone begins.
|
||||
|
||||
## Milestone 1 — Protect playback credentials and resume data
|
||||
|
||||
- Redact credentials and private resolved media URLs from RelayTV public
|
||||
status, SSE, queue, and history payloads.
|
||||
- Sanitize media identifiers again in Home Assistant before exposing entity
|
||||
state or writing storage.
|
||||
- Replace raw resume-URL keys with stable, credential-free keys.
|
||||
- Migrate legacy resume data without retaining secrets, cap its size, and
|
||||
avoid unnecessary storage writes.
|
||||
- Add server and integration regression tests for redaction and migration.
|
||||
|
||||
Acceptance: no API key, bearer token, signed stream credential, or private
|
||||
resolved stream URL is present in public server payloads, Home Assistant state,
|
||||
or the RelayTV runtime store.
|
||||
|
||||
## Milestone 2 — Authenticate and report failures correctly
|
||||
|
||||
- Add optional RelayTV API-token configuration using a password selector.
|
||||
- Send bearer authentication on all write and upload requests.
|
||||
- Validate connectivity and credentials during setup, options, and reauth.
|
||||
- Preserve HTTP status/error context and raise Home Assistant-native errors.
|
||||
- Restrict compatibility fallbacks to genuinely missing endpoints.
|
||||
|
||||
Acceptance: protected and unprotected RelayTV servers both work; rejected or
|
||||
failed actions visibly fail in Home Assistant rather than reporting success.
|
||||
|
||||
## Milestone 3 — Correct targeting and media-player behavior
|
||||
|
||||
- Preserve the configured sidebar target across restart and multiple entries.
|
||||
- Never fall back to another TV when an explicit target cannot be resolved.
|
||||
- Add current RelayTV mute support and expose mute state.
|
||||
- Register a Home Assistant device with stable metadata and configuration URL.
|
||||
- Correct zero-valued position/duration parsing and normalize idle snapshots.
|
||||
|
||||
Acceptance: multi-server actions affect only their intended targets and the
|
||||
media-player entity accurately represents current RelayTV capabilities.
|
||||
|
||||
## Milestone 4 — Automated and live verification
|
||||
|
||||
- Add pytest coverage for URL handling, coordinator merging, API errors,
|
||||
config flows, service targeting, panel persistence, and media properties.
|
||||
- Run Ruff, pytest, Hassfest-compatible validation, and whitespace checks.
|
||||
- Exercise the installed integration against the live RelayTV and Home
|
||||
Assistant containers.
|
||||
- Confirm the authenticated Home Assistant entity and sidebar with Playwright.
|
||||
|
||||
Acceptance: automated checks pass and the live UI has no integration-related
|
||||
console or network failures.
|
||||
|
||||
## Milestone 5 — HACS release preparation
|
||||
|
||||
- Bump the integration to `0.4.0`.
|
||||
- Update README, changelog, and release notes with migration/security guidance.
|
||||
- Confirm the HACS release archive layout and manifest contents.
|
||||
|
||||
Acceptance: the branch is ready for review as a HACS release PR; release
|
||||
tagging and publication remain with the repository's release workflow.
|
||||
@@ -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), ""))
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "RelayTV",
|
||||
"content_in_root": false,
|
||||
"render_readme": true,
|
||||
"homeassistant": "2023.10.0",
|
||||
"homeassistant": "2026.7.0",
|
||||
"zip_release": true,
|
||||
"filename": "relaytv.zip"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py313"
|
||||
@@ -0,0 +1,5 @@
|
||||
homeassistant==2026.7.2
|
||||
home-assistant-frontend==20260624.5
|
||||
pytest==9.0.3
|
||||
pytest-homeassistant-custom-component==0.13.346
|
||||
ruff==0.15.10
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Shared Home Assistant test fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def auto_enable_custom_integrations(enable_custom_integrations):
|
||||
"""Allow loading RelayTV from custom_components in tests."""
|
||||
yield
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests for RelayTV configuration and authentication flows."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.relaytv.const import CONF_API_TOKEN, CONF_BASE_URL, CONF_SERVER_NAME, DOMAIN
|
||||
|
||||
|
||||
async def test_user_flow_validates_and_stores_token(hass, aioclient_mock) -> None:
|
||||
base_url = "http://relaytv.local:8787"
|
||||
aioclient_mock.get(f"{base_url}/status", json={"state": "idle"})
|
||||
aioclient_mock.post(f"{base_url}/auth/check", json={"ok": True, "token_required": True})
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data={
|
||||
CONF_BASE_URL: base_url,
|
||||
CONF_SERVER_NAME: "Living Room",
|
||||
CONF_API_TOKEN: "testing-token",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Living Room"
|
||||
assert result["data"] == {
|
||||
CONF_BASE_URL: base_url,
|
||||
"name": "Living Room",
|
||||
CONF_API_TOKEN: "testing-token",
|
||||
}
|
||||
|
||||
|
||||
async def test_user_flow_reports_invalid_auth(hass, aioclient_mock) -> None:
|
||||
base_url = "http://relaytv.local:8787"
|
||||
aioclient_mock.get(f"{base_url}/status", json={"state": "idle"})
|
||||
aioclient_mock.post(
|
||||
f"{base_url}/auth/check",
|
||||
status=401,
|
||||
json={"detail": "api token required"},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data={
|
||||
CONF_BASE_URL: base_url,
|
||||
CONF_SERVER_NAME: "Living Room",
|
||||
CONF_API_TOKEN: "wrong",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_user_flow_rejects_credentialed_url_without_request(hass, aioclient_mock) -> None:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data={
|
||||
CONF_BASE_URL: "http://user:password@relaytv.local:8787?token=secret",
|
||||
CONF_SERVER_NAME: "Living Room",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_base_url"}
|
||||
assert not aioclient_mock.mock_calls
|
||||
|
||||
|
||||
async def test_reauth_replaces_rejected_token(hass, aioclient_mock) -> None:
|
||||
base_url = "http://relaytv.local:8787"
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Living Room",
|
||||
data={CONF_BASE_URL: base_url, "name": "Living Room", CONF_API_TOKEN: "old-token"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id},
|
||||
data=entry.data,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
aioclient_mock.get(f"{base_url}/status", json={"state": "idle"})
|
||||
aioclient_mock.post(f"{base_url}/auth/check", json={"ok": True, "token_required": True})
|
||||
with patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_API_TOKEN: "new-token"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data[CONF_API_TOKEN] == "new-token"
|
||||
|
||||
|
||||
async def test_reconfigure_updates_connection_and_title(hass, aioclient_mock) -> None:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Living Room",
|
||||
unique_id="http://old-relaytv.local:8787",
|
||||
data={CONF_BASE_URL: "http://old-relaytv.local:8787", "name": "Living Room"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
base_url = "https://new-relaytv.local:8787"
|
||||
aioclient_mock.get(f"{base_url}/status", json={"state": "idle"})
|
||||
aioclient_mock.post(f"{base_url}/auth/check", json={"ok": True, "token_required": False})
|
||||
with patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_BASE_URL: base_url,
|
||||
CONF_SERVER_NAME: "Theater",
|
||||
CONF_API_TOKEN: "",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert entry.title == "Theater"
|
||||
assert entry.unique_id == base_url
|
||||
assert entry.data == {CONF_BASE_URL: base_url, "name": "Theater"}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests for RelayTV coordinator state deduplication."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from custom_components.relaytv.coordinator import RelayTVCoordinator, _material_state_view
|
||||
|
||||
|
||||
def test_idle_snapshots_ignore_stale_audio_properties() -> None:
|
||||
compact_idle = {"playing": False, "paused": False, "volume": 80, "mute": True}
|
||||
full_idle = {"playing": False, "paused": False, "volume": None, "mute": None}
|
||||
|
||||
assert _material_state_view(compact_idle) == _material_state_view(full_idle)
|
||||
|
||||
|
||||
def test_playing_snapshots_track_audio_properties() -> None:
|
||||
first = {"playing": True, "paused": False, "volume": 80, "mute": False}
|
||||
second = {"playing": True, "paused": False, "volume": 30, "mute": True}
|
||||
|
||||
assert _material_state_view(first) != _material_state_view(second)
|
||||
|
||||
|
||||
async def test_note_position_update_stamps_only_position_changes(hass) -> None:
|
||||
coordinator = RelayTVCoordinator(hass=hass, api=MagicMock(base_url="http://relaytv.local:8787"))
|
||||
assert coordinator.position_updated_at is None
|
||||
|
||||
coordinator.note_position_update({"playing": True, "paused": False, "position": 10.0})
|
||||
first_stamp = coordinator.position_updated_at
|
||||
assert first_stamp is not None
|
||||
|
||||
coordinator.note_position_update({"playing": True, "paused": False, "position": 10.0, "volume": 50})
|
||||
assert coordinator.position_updated_at == first_stamp
|
||||
|
||||
coordinator.note_position_update({"playing": True, "paused": False, "position": 25.0})
|
||||
assert coordinator.position_updated_at is not None
|
||||
assert coordinator.position_updated_at >= first_stamp
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for RelayTV media-player behavior."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from homeassistant.components.media_player.const import MediaPlayerEntityFeature
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.relaytv.media_player import RelayTVMediaPlayer, _parse_status
|
||||
|
||||
|
||||
def test_status_parser_preserves_zero_values() -> None:
|
||||
status = _parse_status(
|
||||
{
|
||||
"playing": True,
|
||||
"volume": 0,
|
||||
"position": 0,
|
||||
"pos": 25,
|
||||
"duration": 0,
|
||||
"len": 120,
|
||||
"mute": True,
|
||||
}
|
||||
)
|
||||
|
||||
assert status.volume == 0
|
||||
assert status.position == 0
|
||||
assert status.duration == 0
|
||||
assert status.muted is True
|
||||
|
||||
|
||||
def test_status_parser_reads_volume_on_percent_scale() -> None:
|
||||
# RelayTV reports 0-100; a raw 1 is 1%, not full volume.
|
||||
assert _parse_status({"volume": 1}).volume == 0.01
|
||||
assert _parse_status({"volume": 100}).volume == 1.0
|
||||
|
||||
|
||||
async def test_media_player_exposes_device_and_native_mute(hass) -> None:
|
||||
entry = MockConfigEntry(
|
||||
domain="relaytv",
|
||||
title="Living Room",
|
||||
data={"base_url": "http://relaytv.local:8787", "name": "Living Room"},
|
||||
)
|
||||
coordinator = MagicMock()
|
||||
coordinator.data = {"playing": True, "volume": 25, "mute": False}
|
||||
coordinator.last_update_success = True
|
||||
coordinator.async_request_refresh = AsyncMock()
|
||||
api = MagicMock(base_url="http://relaytv.local:8787")
|
||||
api.mute = AsyncMock(return_value=True)
|
||||
player = RelayTVMediaPlayer(entry, coordinator, api)
|
||||
|
||||
assert player.device_info["name"] == "Living Room"
|
||||
assert player.device_info["manufacturer"] == "RelayTV"
|
||||
assert player.volume_level == 0.25
|
||||
assert player.is_volume_muted is False
|
||||
assert player.supported_features & MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
await player.async_mute_volume(True)
|
||||
|
||||
api.mute.assert_awaited_once_with(True)
|
||||
coordinator.async_request_refresh.assert_awaited_once_with()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for RelayTV HTTP error and authentication behavior."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.relaytv.relaytv_api import (
|
||||
RelayTVApi,
|
||||
RelayTVAuthError,
|
||||
RelayTVResponseError,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
"""Minimal aiohttp response context manager."""
|
||||
|
||||
def __init__(self, status: int, payload: dict[str, Any]) -> None:
|
||||
self.status = status
|
||||
self.payload = payload
|
||||
self.reason = "test response"
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args) -> None:
|
||||
return None
|
||||
|
||||
async def json(self, **_kwargs):
|
||||
return self.payload
|
||||
|
||||
async def text(self) -> str:
|
||||
return str(self.payload)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Record requests and return predefined responses."""
|
||||
|
||||
def __init__(self, responses: list[FakeResponse]) -> None:
|
||||
self.responses = responses
|
||||
self.requests: list[dict[str, Any]] = []
|
||||
|
||||
def request(self, method: str, url: str, **kwargs):
|
||||
self.requests.append({"method": method, "url": url, **kwargs})
|
||||
return self.responses.pop(0)
|
||||
|
||||
def post(self, url: str, **kwargs):
|
||||
return self.request("POST", url, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_sends_bearer_token() -> None:
|
||||
session = FakeSession(
|
||||
[
|
||||
FakeResponse(200, {"state": "idle"}),
|
||||
FakeResponse(200, {"ok": True, "token_required": True}),
|
||||
]
|
||||
)
|
||||
api = RelayTVApi(session, "http://relaytv.local:8787", api_token="testing-token")
|
||||
|
||||
await api.validate()
|
||||
|
||||
assert [request["headers"] for request in session.requests] == [
|
||||
{"Authorization": "Bearer testing-token"},
|
||||
{"Authorization": "Bearer testing-token"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_failure_raises_and_requests_reauth() -> None:
|
||||
auth_failures: list[bool] = []
|
||||
session = FakeSession([FakeResponse(401, {"detail": "api token required"})])
|
||||
api = RelayTVApi(
|
||||
session,
|
||||
"http://relaytv.local:8787",
|
||||
api_token="wrong",
|
||||
on_auth_failure=lambda: auth_failures.append(True),
|
||||
)
|
||||
|
||||
with pytest.raises(RelayTVAuthError):
|
||||
await api.pause()
|
||||
|
||||
assert auth_failures == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_error_does_not_trigger_playback_fallback() -> None:
|
||||
session = FakeSession([FakeResponse(503, {"detail": "backend unavailable"})])
|
||||
api = RelayTVApi(session, "http://relaytv.local:8787")
|
||||
|
||||
with pytest.raises(RelayTVResponseError) as err:
|
||||
await api.playback_play()
|
||||
|
||||
assert err.value.status == 503
|
||||
assert len(session.requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mute_sets_explicit_boolean() -> None:
|
||||
session = FakeSession([FakeResponse(200, {"ok": True, "mute": True})])
|
||||
api = RelayTVApi(session, "http://relaytv.local:8787")
|
||||
|
||||
await api.mute(True)
|
||||
|
||||
assert session.requests[0]["url"] == "http://relaytv.local:8787/mute"
|
||||
assert session.requests[0]["json"] == {"set": True}
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Tests for RelayTV resume-storage migration."""
|
||||
|
||||
from custom_components.relaytv import MAX_RESUME_POSITIONS, _migrate_resume_positions
|
||||
|
||||
|
||||
def test_resume_migration_removes_credentials_and_deduplicates() -> None:
|
||||
value = {
|
||||
"https://media.example/watch?v=123&api_key=first": 120.0,
|
||||
"https://MEDIA.example/watch?api_key=second&v=123": 150.0,
|
||||
"https://media.example/invalid": "not-a-position",
|
||||
}
|
||||
|
||||
result = _migrate_resume_positions(value)
|
||||
|
||||
assert result == {"https://media.example/watch?v=123": 150.0}
|
||||
assert "api_key" not in repr(result)
|
||||
|
||||
|
||||
def test_resume_migration_caps_oldest_entries() -> None:
|
||||
value = {f"https://media.example/watch/{index}": index for index in range(MAX_RESUME_POSITIONS + 5)}
|
||||
|
||||
result = _migrate_resume_positions(value)
|
||||
|
||||
assert len(result) == MAX_RESUME_POSITIONS
|
||||
assert "https://media.example/watch/0" not in result
|
||||
assert f"https://media.example/watch/{MAX_RESUME_POSITIONS + 4}" in result
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Tests for RelayTV config-entry setup."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.relaytv import async_setup_entry
|
||||
from custom_components.relaytv.const import CONF_API_TOKEN, CONF_BASE_URL, DOMAIN
|
||||
|
||||
|
||||
async def test_setup_rejects_invalid_write_credentials(hass, aioclient_mock) -> None:
|
||||
base_url = "http://relaytv.local:8787"
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Living Room",
|
||||
data={CONF_BASE_URL: base_url, "name": "Living Room", CONF_API_TOKEN: "wrong"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
aioclient_mock.get(f"{base_url}/status", json={"state": "idle"})
|
||||
aioclient_mock.post(
|
||||
f"{base_url}/auth/check",
|
||||
status=401,
|
||||
json={"detail": "api token required"},
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigEntryAuthFailed):
|
||||
await async_setup_entry(hass, entry)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for RelayTV service and sidebar targeting."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import ServiceCall
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.relaytv import (
|
||||
_async_set_default_sidebar_target,
|
||||
_async_update_panel,
|
||||
_resolve_entry_ids_for_call,
|
||||
)
|
||||
from custom_components.relaytv.const import (
|
||||
CONF_PANEL_ENABLED,
|
||||
CONF_PANEL_TARGET_ENTRY_ID,
|
||||
DATA_API,
|
||||
DATA_PANEL_SETTINGS,
|
||||
DATA_STORE,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
def _add_entry(hass, title: str) -> MockConfigEntry:
|
||||
entry = MockConfigEntry(domain=DOMAIN, title=title, data={"base_url": f"http://{title.lower()}:8787"})
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
async def test_explicit_non_relaytv_target_never_uses_fallback(hass) -> None:
|
||||
relay_entry = _add_entry(hass, "RelayTV")
|
||||
other_entry = MockConfigEntry(domain="test", title="Other", data={})
|
||||
other_entry.add_to_hass(hass)
|
||||
registry = er.async_get(hass)
|
||||
other_entity = registry.async_get_or_create(
|
||||
"media_player",
|
||||
"test",
|
||||
"other-player",
|
||||
config_entry=other_entry,
|
||||
suggested_object_id="other_player",
|
||||
)
|
||||
hass.data[DOMAIN] = {relay_entry.entry_id: {DATA_API: MagicMock()}}
|
||||
call = ServiceCall(hass, DOMAIN, "play_now", {"entity_id": other_entity.entity_id})
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="does not contain a loaded RelayTV"):
|
||||
_resolve_entry_ids_for_call(hass, call)
|
||||
|
||||
|
||||
async def test_no_target_uses_saved_panel_target(hass) -> None:
|
||||
first = _add_entry(hass, "First")
|
||||
second = _add_entry(hass, "Second")
|
||||
hass.data[DOMAIN] = {
|
||||
first.entry_id: {DATA_API: MagicMock()},
|
||||
second.entry_id: {DATA_API: MagicMock()},
|
||||
DATA_PANEL_SETTINGS: {CONF_PANEL_TARGET_ENTRY_ID: second.entry_id},
|
||||
}
|
||||
|
||||
call = ServiceCall(hass, DOMAIN, "play_now", {})
|
||||
|
||||
assert _resolve_entry_ids_for_call(hass, call) == [second.entry_id]
|
||||
|
||||
|
||||
async def test_setup_does_not_overwrite_saved_panel_target(hass) -> None:
|
||||
first = _add_entry(hass, "First")
|
||||
second = _add_entry(hass, "Second")
|
||||
store = MagicMock()
|
||||
store.async_save = AsyncMock()
|
||||
settings = {CONF_PANEL_ENABLED: True, CONF_PANEL_TARGET_ENTRY_ID: first.entry_id}
|
||||
hass.data[DOMAIN] = {DATA_STORE: store, DATA_PANEL_SETTINGS: settings}
|
||||
|
||||
await _async_set_default_sidebar_target(hass, second)
|
||||
|
||||
assert settings[CONF_PANEL_TARGET_ENTRY_ID] == first.entry_id
|
||||
store.async_save.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_panel_waits_for_saved_target_to_finish_loading(hass) -> None:
|
||||
selected = _add_entry(hass, "Selected")
|
||||
fallback = _add_entry(hass, "Fallback")
|
||||
store = MagicMock()
|
||||
store.async_save = AsyncMock()
|
||||
settings = {CONF_PANEL_ENABLED: True, CONF_PANEL_TARGET_ENTRY_ID: selected.entry_id}
|
||||
hass.data[DOMAIN] = {
|
||||
DATA_STORE: store,
|
||||
DATA_PANEL_SETTINGS: settings,
|
||||
fallback.entry_id: {DATA_API: MagicMock(base_url="http://fallback:8787")},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("custom_components.relaytv._async_unregister_panel") as unregister,
|
||||
patch("custom_components.relaytv._register_panel") as register,
|
||||
):
|
||||
await _async_update_panel(hass)
|
||||
|
||||
assert settings[CONF_PANEL_TARGET_ENTRY_ID] == selected.entry_id
|
||||
store.async_save.assert_not_awaited()
|
||||
unregister.assert_not_called()
|
||||
register.assert_not_called()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests for RelayTV URL safety helpers."""
|
||||
|
||||
from custom_components.relaytv.url_utils import canonical_media_key, sanitize_url
|
||||
|
||||
|
||||
def test_sanitize_url_removes_credentials_and_signatures() -> None:
|
||||
value = (
|
||||
"HTTP://user:password@Media.Example/Videos/abc/stream"
|
||||
"?api_key=secret&static=true&X-Amz-Signature=signed#fragment"
|
||||
)
|
||||
|
||||
assert sanitize_url(value) == "http://media.example/Videos/abc/stream?static=true"
|
||||
|
||||
|
||||
def test_sanitize_url_strips_emby_jellyfin_and_jwt_auth_keys() -> None:
|
||||
value = (
|
||||
"https://media.example/watch"
|
||||
"?v=1&X-Emby-Token=secret&X-Jellyfin-Token=secret&jwt=secret&auth=secret&exp=99"
|
||||
)
|
||||
|
||||
assert sanitize_url(value) == "https://media.example/watch?v=1"
|
||||
|
||||
|
||||
def test_sanitize_url_filters_relative_url_queries() -> None:
|
||||
assert sanitize_url("/watch?token=secret&v=123") == "/watch?v=123"
|
||||
assert sanitize_url("/thumbs/abc.jpg") == "/thumbs/abc.jpg"
|
||||
|
||||
|
||||
def test_sanitize_url_preserves_ipv6_hosts() -> None:
|
||||
value = "http://[2001:db8::1]:8787/watch?api_key=secret&v=1"
|
||||
|
||||
assert sanitize_url(value) == "http://[2001:db8::1]:8787/watch?v=1"
|
||||
|
||||
|
||||
def test_canonical_media_key_sorts_safe_query_parameters() -> None:
|
||||
first = "https://media.example/watch?v=123&lang=en&token=secret"
|
||||
second = "https://MEDIA.example/watch?lang=en&v=123&token=other"
|
||||
|
||||
assert canonical_media_key(first) == canonical_media_key(second)
|
||||
assert canonical_media_key(first) == "https://media.example/watch?lang=en&v=123"
|
||||
Reference in New Issue
Block a user