From 272f6ce7697d27012bb74aef94f1b8e45fcf7ef2 Mon Sep 17 00:00:00 2001 From: mcgeezy Date: Wed, 15 Apr 2026 15:53:03 -0500 Subject: [PATCH] Add RelayTV media upload services --- README.md | 32 ++++++- custom_components/relaytv/README.md | 5 ++ custom_components/relaytv/__init__.py | 101 ++++++++++++++++++++++- custom_components/relaytv/const.py | 3 + custom_components/relaytv/relaytv_api.py | 61 ++++++++++++++ custom_components/relaytv/services.yaml | 81 ++++++++++++++++++ 6 files changed, 281 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 314ab0f..79a2bc4 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/custom_components/relaytv/README.md b/custom_components/relaytv/README.md index e680488..06a7da4 100644 --- a/custom_components/relaytv/README.md +++ b/custom_components/relaytv/README.md @@ -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. diff --git a/custom_components/relaytv/__init__.py b/custom_components/relaytv/__init__.py index d45494a..f47bcf0 100644 --- a/custom_components/relaytv/__init__.py +++ b/custom_components/relaytv/__init__.py @@ -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) diff --git a/custom_components/relaytv/const.py b/custom_components/relaytv/const.py index b147ddb..6fb3dd8 100644 --- a/custom_components/relaytv/const.py +++ b/custom_components/relaytv/const.py @@ -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" diff --git a/custom_components/relaytv/relaytv_api.py b/custom_components/relaytv/relaytv_api.py index 099f02a..8b175c0 100644 --- a/custom_components/relaytv/relaytv_api.py +++ b/custom_components/relaytv/relaytv_api.py @@ -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, *, diff --git a/custom_components/relaytv/services.yaml b/custom_components/relaytv/services.yaml index 5e882d7..1b3c95c 100644 --- a/custom_components/relaytv/services.yaml +++ b/custom_components/relaytv/services.yaml @@ -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: