Add RelayTV media upload services
This commit is contained in:
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user