Add RelayTV media upload services

This commit is contained in:
2026-04-15 15:54:01 -05:00
parent 650394634a
commit 272f6ce769
6 changed files with 281 additions and 2 deletions
+31 -1
View File
@@ -25,7 +25,7 @@ RelayTV integrates with Home Assistant as a local `media_player` plus RelayTV-sp
- Supports multiple RelayTV servers
- Uses RelayTV live event updates plus `/status` refresh fallback
- Adds a Home Assistant sidebar panel for the RelayTV web UI
- Exposes RelayTV-specific services for smart play, temporary playback, overlays, snapshots, synchronized playback, and resume behavior
- 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
### Supported media controls
@@ -48,6 +48,7 @@ RelayTV integrates with Home Assistant as a local `media_player` plus RelayTV-sp
- Snapshot capture
- Multi-target synchronized playback
- Resume-position support
- Direct media upload from Home Assistant local media/files to RelayTV ingest endpoints
- Optional sensor-to-stream mapping triggers
---
@@ -77,6 +78,9 @@ _Add screenshots here for release._
| `relaytv.play_synced` | `POST /play_at` | Multi-entity time-aligned start |
| `relaytv.snapshot` | `POST /snapshot` (fallback `GET /snapshot`) | Captures current frame |
| `relaytv.play_with_resume` | `POST /play` + `POST /seek_abs` | Resume per-URL saved position |
| `relaytv.upload_media` | `POST /ingest/media` | Upload local HA media/file and return RelayTV media URL |
| `relaytv.upload_media_play` | `POST /ingest/media/play` | Upload local HA media/file and start playback |
| `relaytv.upload_media_enqueue` | `POST /ingest/media/enqueue` | Upload local HA media/file and append to queue |
---
@@ -165,17 +169,43 @@ data:
position: top-right
```
### Upload media and play
```yaml
service: relaytv.upload_media_play
target:
entity_id: media_player.relaytv_living_room
data:
file_path: /config/www/clip.mp4
title: Shared Clip
```
`file_path` must be readable by Home Assistant and allowed by `allowlist_external_dirs`.
When using the service UI, the `file` field can also select a local Home Assistant media source item.
---
## Typical Use Cases
- Send shared links from Home Assistant automations to a RelayTV screen
- Upload and play local Home Assistant media files on RelayTV
- Launch temporary doorbell or announcement media, then resume previous playback
- Display overlay messages on TVs around the home
- Add RelayTV as a dashboard-accessible media target
- Keep multiple RelayTV devices available in one Home Assistant setup
- Start synchronized playback across more than one RelayTV screen
---
## Known Limitations
- `relaytv.play_now` currently maps to RelayTV `POST /play` (queue-clearing behavior)
- RelayTV also exposes `POST /play_now`, but this integration does not currently use its preserve-current behavior
- No dedicated `clear_queue` Home Assistant service is currently registered by this integration
- Upload services require a local media source item or a file path available inside the Home Assistant container
- 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
---
+5
View File
@@ -20,6 +20,9 @@ This integration provides a RelayTV `media_player` entity, RelayTV service actio
- `play_synced`
- `snapshot`
- `play_with_resume`
- `upload_media`
- `upload_media_play`
- `upload_media_enqueue`
## Setup
@@ -42,6 +45,8 @@ This integration provides a RelayTV `media_player` entity, RelayTV service actio
- `smart_url` uses RelayTV `POST /smart`, which enqueues while already playing and otherwise starts playback immediately.
- `play_now` and `announce` currently target RelayTV `POST /play`.
- RelayTV also exposes `POST /play_now`, but this integration does not currently use its preserve-current behavior.
- Upload services target RelayTV `POST /ingest/media`, `POST /ingest/media/play`, and `POST /ingest/media/enqueue`.
- Upload services accept either a Home Assistant local media source selection or an allowlisted `file_path` visible inside the Home Assistant container.
- Overlay requires `text` or `image_url`.
- Snapshot requires active playback on the RelayTV server.
- Snapshot responses are normalized to absolute URLs for Home Assistant entity attributes.
+100 -1
View File
@@ -3,13 +3,16 @@
from __future__ import annotations
import logging
from pathlib import Path
import time
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, callback
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import aiohttp_client, entity_registry as er
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.storage import Store
@@ -38,6 +41,9 @@ from .const import (
SERVICE_PLAY_WITH_RESUME,
SERVICE_SMART_URL,
SERVICE_SNAPSHOT,
SERVICE_UPLOAD_MEDIA,
SERVICE_UPLOAD_MEDIA_ENQUEUE,
SERVICE_UPLOAD_MEDIA_PLAY,
)
from .coordinator import RelayTVCoordinator
from .relaytv_api import RelayTVApi
@@ -171,6 +177,39 @@ def _resolve_entry_id_for_call(hass: HomeAssistant, call: ServiceCall) -> str |
return entry_ids[0] if entry_ids else None
async def _resolve_upload_path(hass: HomeAssistant, call: ServiceCall) -> str:
media = call.data.get("file")
if isinstance(media, dict):
media_content_id = media.get("media_content_id")
if isinstance(media_content_id, str) and media_content_id:
resolved = await async_resolve_media(hass, media_content_id, None)
if resolved.path is None:
raise ServiceValidationError("RelayTV upload requires a local media file")
path = str(resolved.path)
if await hass.async_add_executor_job(Path(path).is_file):
return path
raise ServiceValidationError(f"RelayTV upload file does not exist: {path}")
file_path = str(call.data.get("file_path") or "").strip()
if not file_path:
raise ServiceValidationError("RelayTV upload requires either file or file_path")
allowed = await hass.async_add_executor_job(hass.config.is_allowed_path, file_path)
if not allowed:
raise ServiceValidationError(
f"RelayTV upload file path is not allowed by Home Assistant: {file_path}. "
"Add the directory to allowlist_external_dirs."
)
if not await hass.async_add_executor_job(Path(file_path).is_file):
raise ServiceValidationError(f"RelayTV upload file does not exist: {file_path}")
return file_path
def _upload_title(call: ServiceCall, path: str) -> str | None:
title = str(call.data.get("title") or "").strip()
return title or Path(path).stem
def _resolve_entries_for_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[str]:
registry = er.async_get(hass)
result: list[str] = []
@@ -429,6 +468,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await store[DATA_API].seek_abs(float(resume_position))
await store[DATA_COORDINATOR].async_request_refresh()
async def _handle_upload_media(call: ServiceCall):
path = await _resolve_upload_path(hass, call)
title = _upload_title(call, path)
results: list[dict] = []
for entry_id in _resolve_entry_ids_for_call(hass, call):
store = _get_entry_data(hass, entry_id)
if not store:
continue
data = await store[DATA_API].upload_media(path, title=title)
if data is None:
raise ServiceValidationError(f"RelayTV media upload failed for {store[DATA_API].base_url}")
results.append({"entry_id": entry_id, "base_url": store[DATA_API].base_url, "response": data})
return {"results": results}
async def _handle_upload_media_play(call: ServiceCall):
path = await _resolve_upload_path(hass, call)
title = _upload_title(call, path)
results: list[dict] = []
for entry_id in _resolve_entry_ids_for_call(hass, call):
store = _get_entry_data(hass, entry_id)
if not store:
continue
data = await store[DATA_API].upload_media_play(path, title=title)
if data is None:
raise ServiceValidationError(f"RelayTV media upload/play failed for {store[DATA_API].base_url}")
await store[DATA_COORDINATOR].async_request_refresh()
results.append({"entry_id": entry_id, "base_url": store[DATA_API].base_url, "response": data})
return {"results": results}
async def _handle_upload_media_enqueue(call: ServiceCall):
path = await _resolve_upload_path(hass, call)
title = _upload_title(call, path)
results: list[dict] = []
for entry_id in _resolve_entry_ids_for_call(hass, call):
store = _get_entry_data(hass, entry_id)
if not store:
continue
data = await store[DATA_API].upload_media_enqueue(path, title=title)
if data is None:
raise ServiceValidationError(f"RelayTV media upload/enqueue failed for {store[DATA_API].base_url}")
await store[DATA_COORDINATOR].async_request_refresh()
results.append({"entry_id": entry_id, "base_url": store[DATA_API].base_url, "response": data})
return {"results": results}
for service_name, handler in (
(SERVICE_SMART_URL, _handle_smart_url),
(SERVICE_PLAY_NOW, _handle_play_now),
@@ -442,6 +525,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not hass.services.has_service(DOMAIN, service_name):
hass.services.async_register(DOMAIN, service_name, handler)
for service_name, handler in (
(SERVICE_UPLOAD_MEDIA, _handle_upload_media),
(SERVICE_UPLOAD_MEDIA_PLAY, _handle_upload_media_play),
(SERVICE_UPLOAD_MEDIA_ENQUEUE, _handle_upload_media_enqueue),
):
if not hass.services.has_service(DOMAIN, service_name):
hass.services.async_register(
DOMAIN,
service_name,
handler,
supports_response=SupportsResponse.OPTIONAL,
)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
@@ -471,6 +567,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
SERVICE_PLAY_SYNCED,
SERVICE_SNAPSHOT,
SERVICE_PLAY_WITH_RESUME,
SERVICE_UPLOAD_MEDIA,
SERVICE_UPLOAD_MEDIA_PLAY,
SERVICE_UPLOAD_MEDIA_ENQUEUE,
):
if hass.services.has_service(DOMAIN, service_name):
hass.services.async_remove(DOMAIN, service_name)
+3
View File
@@ -25,6 +25,9 @@ SERVICE_OVERLAY = "overlay"
SERVICE_PLAY_SYNCED = "play_synced"
SERVICE_SNAPSHOT = "snapshot"
SERVICE_PLAY_WITH_RESUME = "play_with_resume"
SERVICE_UPLOAD_MEDIA = "upload_media"
SERVICE_UPLOAD_MEDIA_PLAY = "upload_media_play"
SERVICE_UPLOAD_MEDIA_ENQUEUE = "upload_media_enqueue"
CONF_SENSOR_STREAM_MAPPINGS = "sensor_stream_mappings"
CONF_RESUME_POSITIONS = "resume_positions"
+61
View File
@@ -8,6 +8,9 @@ We intentionally prefer the canonical endpoints:
- POST /play
- POST /smart
- POST /enqueue
- POST /ingest/media
- POST /ingest/media/play
- POST /ingest/media/enqueue
- POST /next
- POST /pause | /resume | /toggle_pause
- POST /playback/play
@@ -22,12 +25,15 @@ from __future__ import annotations
import asyncio
import logging
import mimetypes
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
import aiohttp
_LOGGER = logging.getLogger(__name__)
_UPLOAD_CHUNK_SIZE = 1024 * 1024
def _join(base: str, path: str) -> str:
@@ -36,6 +42,18 @@ def _join(base: str, path: str) -> str:
return f"{base}/{path}" if path else base
async def _iter_file_chunks(file_path: Path):
file_obj = await asyncio.to_thread(file_path.open, "rb")
try:
while True:
chunk = await asyncio.to_thread(file_obj.read, _UPLOAD_CHUNK_SIZE)
if not chunk:
break
yield chunk
finally:
await asyncio.to_thread(file_obj.close)
@dataclass
class RelayTVApi:
"""Small wrapper around RelayTV HTTP endpoints."""
@@ -43,6 +61,7 @@ class RelayTVApi:
session: aiohttp.ClientSession
base_url: str
timeout_s: float = 8.0
upload_timeout_s: float = 3600.0
def url_for(self, path: str) -> str:
"""Build an absolute RelayTV URL for a relative API path."""
@@ -68,6 +87,36 @@ class RelayTVApi:
except Exception:
return None
async def _upload_media(
self,
path: str,
*,
endpoint: str,
title: str | None = None,
) -> Optional[dict[str, Any]]:
file_path = Path(path)
url = self.url_for(endpoint)
filename = file_path.name
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
form = aiohttp.FormData()
if title:
form.add_field("title", title)
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:
if resp.status >= 400:
_LOGGER.debug("RelayTV media upload failed: %s %s", resp.status, await resp.text())
return None
try:
return await resp.json(content_type=None)
except Exception:
return {}
except Exception:
_LOGGER.debug("RelayTV media upload request failed", exc_info=True)
return None
async def get_status(self) -> Optional[dict[str, Any]]:
"""Fetch current playback/status."""
return await self._request_json("GET", "status")
@@ -92,6 +141,18 @@ class RelayTVApi:
data = await self._request_json("POST", "enqueue", json={"url": url})
return data is not None
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)."""
return await self._upload_media(path, endpoint="ingest/media", title=title)
async def upload_media_play(self, path: str, *, title: str | None = None) -> Optional[dict[str, Any]]:
"""Upload local media and start playback (POST /ingest/media/play)."""
return await self._upload_media(path, endpoint="ingest/media/play", title=title)
async def upload_media_enqueue(self, path: str, *, title: str | None = None) -> Optional[dict[str, Any]]:
"""Upload local media and enqueue it (POST /ingest/media/enqueue)."""
return await self._upload_media(path, endpoint="ingest/media/enqueue", title=title)
async def play_temporary(
self,
*,
+81
View File
@@ -188,3 +188,84 @@ play_with_resume:
required: false
selector:
boolean:
upload_media:
name: Upload media
description: Upload a local Home Assistant media file to RelayTV using POST /ingest/media. This stores the file and returns a RelayTV media URL without playing or queueing it.
target:
entity:
domain: media_player
integration: relaytv
device:
integration: relaytv
fields:
file:
required: false
selector:
media:
accept:
- video/*
- audio/*
file_path:
required: false
example: "/config/www/clip.mp4"
selector:
text:
title:
required: false
selector:
text:
upload_media_play:
name: Upload media and play
description: Upload a local Home Assistant media file to RelayTV and start playback using POST /ingest/media/play.
target:
entity:
domain: media_player
integration: relaytv
device:
integration: relaytv
fields:
file:
required: false
selector:
media:
accept:
- video/*
- audio/*
file_path:
required: false
example: "/config/www/clip.mp4"
selector:
text:
title:
required: false
selector:
text:
upload_media_enqueue:
name: Upload media and enqueue
description: Upload a local Home Assistant media file to RelayTV and append it to the queue using POST /ingest/media/enqueue.
target:
entity:
domain: media_player
integration: relaytv
device:
integration: relaytv
fields:
file:
required: false
selector:
media:
accept:
- video/*
- audio/*
file_path:
required: false
example: "/config/www/clip.mp4"
selector:
text:
title:
required: false
selector:
text: