From 3204f079478726722fa947216b4313cf8a5f8758 Mon Sep 17 00:00:00 2001 From: customrenovations Date: Sat, 21 Feb 2026 20:31:17 -0600 Subject: [PATCH] updating for use with new endpoints --- .gitignore | 4 + custom_components/relaytv/__init__.py | 44 +--- custom_components/relaytv/media_player.py | 53 +++-- custom_components/relaytv/relaytv_api.py | 276 ++++++++-------------- custom_components/relaytv/services.yaml | 25 +- 5 files changed, 160 insertions(+), 242 deletions(-) diff --git a/.gitignore b/.gitignore index 5132fbe..999a3ab 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ secrets.yaml # OS specific .DS_Store Thumbs.db + +#Build Tools +build-release.sh +VERSION diff --git a/custom_components/relaytv/__init__.py b/custom_components/relaytv/__init__.py index a1d988e..bba6cab 100644 --- a/custom_components/relaytv/__init__.py +++ b/custom_components/relaytv/__init__.py @@ -5,8 +5,7 @@ This integration: 2) Exposes a RelayTV media_player entity backed by RelayTV's local HTTP API. 3) Provides services (e.g., relaytv.smart_url) for automations and mobile share flows. -The API layer is implemented defensively with endpoint fallbacks so it can be -adapted to RelayTV deployments that may differ slightly. +The API layer targets RelayTV's canonical endpoints (see RelayTV server docs/API.md). """ from __future__ import annotations @@ -94,47 +93,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not url: return await api.smart_url(url) + await coordinator.async_request_refresh() async def _handle_play_now(call): + """Play a URL immediately (maps to RelayTV POST /play). + + Note: RelayTV /play clears the queue. + """ url = (call.data.get("url") or "").strip() if not url: return - preserve_current = call.data.get("preserve_current", True) - reason = call.data.get("reason") - await api.play_now(url=url, preserve_current=preserve_current, reason=reason) + use_ytdlp = call.data.get("use_ytdlp") + cec = call.data.get("cec") + await api.play(url=url, use_ytdlp=use_ytdlp, cec=cec) await coordinator.async_request_refresh() async def _handle_announce(call): - url = (call.data.get("url") or "").strip() - if not url: - return - preserve_current = call.data.get("preserve_current", True) - await api.play_now(url=url, preserve_current=preserve_current, reason="announcement") - await coordinator.async_request_refresh() + # RelayTV doesn't have a distinct "announce" mode; alias to play_now. + await _handle_play_now(call) # Register services once (first config entry wins). if not hass.services.has_service(DOMAIN, SERVICE_SMART_URL): hass.services.async_register(DOMAIN, SERVICE_SMART_URL, _handle_smart_url) - - async def _handle_play_now(call): - url = call.data.get("url") or "" - preserve_current = call.data.get("preserve_current", True) - reason = call.data.get("reason") - title = call.data.get("title") - thumbnail = call.data.get("thumbnail") - if not url: - return - await api.play_now(url=url, preserve_current=preserve_current, reason=reason, title=title, thumbnail=thumbnail) - await coordinator.async_request_refresh() - - async def _handle_announce(call): - url = call.data.get("url") or "" - preserve_current = call.data.get("preserve_current", True) - if not url: - return - await api.play_now(url=url, preserve_current=preserve_current, reason="announcement") - await coordinator.async_request_refresh() - if not hass.services.has_service(DOMAIN, SERVICE_PLAY_NOW): hass.services.async_register(DOMAIN, SERVICE_PLAY_NOW, _handle_play_now) if not hass.services.has_service(DOMAIN, SERVICE_ANNOUNCE): @@ -197,4 +177,4 @@ def _register_panel(hass: HomeAssistant, *, path: str, title: str, icon: str, ur config={"url": url}, require_admin=False, ) - _LOGGER.info("Registered RelayTV Web UI panel at /%s → %s", path, url) \ No newline at end of file + _LOGGER.info("Registered RelayTV Web UI panel at /%s → %s", path, url) diff --git a/custom_components/relaytv/media_player.py b/custom_components/relaytv/media_player.py index 4d8019d..b308d4c 100644 --- a/custom_components/relaytv/media_player.py +++ b/custom_components/relaytv/media_player.py @@ -63,7 +63,7 @@ def _parse_status(data: Optional[dict[str, Any]]) -> _StatusView: if not isinstance(data, dict): return _StatusView() - # Common keys (based on similar local-player APIs) + # Common keys (based on RelayTV docs/API.md) playing = bool(data.get("playing") or data.get("is_playing") or data.get("play")) paused = bool(data.get("paused") or data.get("is_paused") or data.get("pause")) @@ -90,10 +90,24 @@ def _parse_status(data: Optional[dict[str, Any]]) -> _StatusView: title = title or data.get("title") url = url or data.get("url") + # Prefer locally cached thumbnails when present (RelayTV serves /thumbs/.jpg) thumb = None if isinstance(np, dict): - thumb = np.get("thumbnail") or np.get("thumb") or np.get("image") or np.get("art") or np.get("poster") - thumb = thumb or data.get("thumbnail") or data.get("image") or data.get("art") + thumb = ( + np.get("thumbnail_local") + or np.get("thumbnail") + or np.get("thumb") + or np.get("image") + or np.get("art") + or np.get("poster") + ) + thumb = ( + thumb + or data.get("thumbnail_local") + or data.get("thumbnail") + or data.get("image") + or data.get("art") + ) return _StatusView( playing=playing, @@ -139,7 +153,6 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF ) @@ -176,7 +189,8 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): @property def is_volume_muted(self) -> Optional[bool]: - return _parse_status(self.coordinator.data).muted + # RelayTV doesn't currently expose mute as a dedicated API. + return None @property def media_title(self) -> Optional[str]: @@ -205,25 +219,30 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): if t.tzinfo is None: return t.replace(tzinfo=timezone.utc) return t + @property def entity_picture(self) -> Optional[str]: v = _parse_status(self.coordinator.data) return _abs_url(self._entry.data.get("base_url", ""), v.thumbnail) async def async_media_play(self) -> None: - await self._api.command("play") + # RelayTV provides /playback/play with "TV remote" semantics: + # - toggle pause if playing + # - resume closed session if available + # - else play next in queue + await self._api.playback_play() await self.coordinator.async_request_refresh() async def async_media_pause(self) -> None: - await self._api.command("pause") + await self._api.pause() await self.coordinator.async_request_refresh() async def async_media_stop(self) -> None: - await self._api.command("stop") + await self._api.stop() await self.coordinator.async_request_refresh() async def async_media_next_track(self) -> None: - await self._api.command("next") + await self._api.next() await self.coordinator.async_request_refresh() async def async_media_previous_track(self) -> None: @@ -235,21 +254,19 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): await self.coordinator.async_request_refresh() async def async_mute_volume(self, mute: bool) -> None: - await self._api.command("mute", value=bool(mute)) - await self.coordinator.async_request_refresh() - + # Not supported by RelayTV API at this time. + return async def async_turn_on(self) -> None: - # Power-on maps to RelayTV's preferred play semantics (/playback/play): - # unpause if playing, resume closed session, or start next queued item. + # Same behavior as PLAY. await self._api.playback_play() await self.coordinator.async_request_refresh() async def async_turn_off(self) -> None: - # Map power-off to RelayTV close/quit behavior. - await self._api.command("close") + # Treat "turn off" as stop playback. + await self._api.stop() await self.coordinator.async_request_refresh() async def async_media_seek(self, position: float) -> None: - await self._api.command("seek", value=float(position)) - await self.coordinator.async_request_refresh() \ No newline at end of file + await self._api.seek_abs(float(position)) + await self.coordinator.async_request_refresh() diff --git a/custom_components/relaytv/relaytv_api.py b/custom_components/relaytv/relaytv_api.py index f4b2373..98bb3d6 100644 --- a/custom_components/relaytv/relaytv_api.py +++ b/custom_components/relaytv/relaytv_api.py @@ -1,16 +1,27 @@ """RelayTV local HTTP API helper. -This is intentionally defensive: RelayTV deployments may expose slightly different -paths depending on version/build. We try a small set of common candidates. +This integration targets the RelayTV server API documented in relaytv/docs/API.md. +We intentionally prefer the canonical endpoints: + +- GET /status +- POST /play +- POST /smart +- POST /enqueue +- POST /next +- POST /pause | /resume | /toggle_pause +- POST /seek_abs +- POST /volume +- POST /stop + +The wrapper remains defensive around timeouts and JSON parsing. """ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Iterable, Optional - import asyncio import logging +from dataclasses import dataclass +from typing import Any, Optional import aiohttp @@ -34,16 +45,16 @@ class RelayTVApi: async def _request_json( self, method: str, - url: str, + path: str, *, json: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: + url = _join(self.base_url, path) try: async with asyncio.timeout(self.timeout_s): async with self.session.request(method, url, json=json) as resp: if resp.status >= 400: return None - # Some endpoints may return empty body. try: return await resp.json(content_type=None) except Exception: @@ -51,161 +62,74 @@ class RelayTVApi: except Exception: return None - async def _first_success( - self, - method: str, - paths: Iterable[str], - *, - json: Optional[dict[str, Any]] = None, - ) -> Optional[dict[str, Any]]: - for p in paths: - url = _join(self.base_url, p) - data = await self._request_json(method, url, json=json) - if data is not None: - return data - return None - async def get_status(self) -> Optional[dict[str, Any]]: """Fetch current playback/status.""" - return await self._first_success( - "GET", - ( - "status", - "api/status", - "v1/status", - "player/status", - ), - ) + return await self._request_json("GET", "status") async def smart_url(self, url: str) -> bool: - """Smart URL handler used by the HA service relaytv.smart_url. - - Goal: - - If something is already playing, try to ENQUEUE (add to queue) first. - - Otherwise, try the server's smart_url endpoint. - - Only as a last resort, fall back to play-now style endpoints. - """ - payload = {"url": url} - - status = await self.get_status() or {} - - # Determine "currently playing" as defensively as possible. - state = str(status.get("state") or "").lower() - playing_flag = bool(status.get("playing")) and not bool(status.get("paused")) - is_playing = playing_flag or (state == "playing") - - if is_playing: - data = await self._first_success( - "POST", - ( - "queue/add", - "api/queue/add", - "v1/queue/add", - "enqueue", - "api/enqueue", - "v1/enqueue", - "queue", - "api/queue", - "v1/queue", - ), - json=payload, - ) - if data is not None: - return True - - # Prefer true smart endpoints next. - data = await self._first_success( - "POST", - ( - "smart_url", - "api/smart_url", - "v1/smart_url", - "cast/smart_url", - ), - json=payload, - ) - if data is not None: - return True - - # Last resort: endpoints that typically REPLACE current playback. - data = await self._first_success( - "POST", - ( - "cast/url", - "play", - "api/play", - "v1/play", - ), - json=payload, - ) + """RelayTV one-button behavior (POST /smart).""" + data = await self._request_json("POST", "smart", json={"url": url}) return data is not None - - async def command(self, cmd: str, *, value: Optional[Any] = None) -> bool: - """Send a player control command. + async def play(self, url: str, *, use_ytdlp: bool | None = None, cec: bool | None = None) -> bool: + """Immediate play; clears queue (POST /play).""" + payload: dict[str, Any] = {"url": url} + if use_ytdlp is not None: + 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 - This is aligned to the RelayTV server API: + 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 - - Seek scrubber should be absolute: POST /seek_abs {"sec": } - - Volume slider should be absolute: POST /volume {"set": <0-100>} - - Power on / play semantics: POST /playback/play {} + 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 - We still keep a couple of legacy fallbacks for older builds. + 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 + + async def pause(self) -> bool: + return (await self._request_json("POST", "pause", json={})) is not None + + async def resume(self) -> bool: + return (await self._request_json("POST", "resume", json={})) is not None + + async def toggle_pause(self) -> bool: + return (await self._request_json("POST", "toggle_pause", json={})) is not None + + async def stop(self) -> bool: + return (await self._request_json("POST", "stop", json={})) is not None + + async def playback_play(self) -> bool: + """User-facing Play semantics (POST /playback/play). + + RelayTV's server implements "TV remote" behavior here: + - if mpv is running: toggle pause + - else if session is closed: resume + - else: play next queued item + + If the endpoint is missing (older servers), fall back to ensure_playing(). """ - cmd = (cmd or "").strip().lower() - - # Absolute seek (HA provides absolute seconds) - if cmd == "seek" and value is not None: - return await self.seek_abs(float(value)) - - # Volume: HA provides 0.0-1.0 - if cmd == "volume" and value is not None: - return await self.set_volume(value) - - # Preferred play semantics - if cmd == "play": - return await self.playback_play() - - # Simple direct endpoints for common commands - direct_paths = { - "pause": ("pause",), - "toggle_pause": ("toggle_pause", "pause"), - "stop": ("stop",), - "close": ("close",), - "next": ("next", "queue/next", "queue/skip"), - "previous": ("previous",), - } - if cmd in direct_paths: - data = await self._first_success("POST", direct_paths[cmd], json={}) - return data is not None - - # Legacy control-style fallback - payload: dict[str, Any] = {"command": cmd} - if value is not None: - payload["value"] = value - data = await self._first_success("POST", ("control", "api/control", "v1/control"), json=payload) + data = await self._request_json("POST", "playback/play", json={}) if data is not None: return True - - # Legacy "POST /player/" or "/" - data = await self._first_success("POST", (f"player/{cmd}", cmd), json={} if value is None else {"value": value}) - if data is not None: - return True - - _LOGGER.debug("RelayTV command failed: %s value=%s", cmd, value) - return False + return await self.ensure_playing() async def seek_abs(self, sec: float) -> bool: - """Seek to an absolute position in seconds (RelayTV: POST /seek_abs).""" + """Seek to an absolute position in seconds (POST /seek_abs).""" try: sec_f = float(sec) except Exception: return False - data = await self._first_success("POST", ("seek_abs",), json={"sec": sec_f}) - if data is not None: - return True - # Very old fallback (some builds used /seek with sec as absolute) - data = await self._first_success("POST", ("seek",), json={"sec": sec_f}) + data = await self._request_json("POST", "seek_abs", json={"sec": sec_f}) return data is not None async def set_volume(self, level: Any) -> bool: @@ -214,6 +138,7 @@ class RelayTVApi: v = float(level) except Exception: return False + # Normalize if v <= 1.0: pct = v * 100.0 @@ -221,48 +146,35 @@ class RelayTVApi: pct = v pct = max(0.0, min(200.0, float(pct))) - # RelayTV expects {"set": } (and supports {"delta": } for relative changes) + # RelayTV expects {"set": } for val in (pct, round(pct), int(round(pct))): - data = await self._first_success("POST", ("volume",), json={"set": val}) + data = await self._request_json("POST", "volume", json={"set": val}) if data is not None: return True - # fallback via control (rare) - data = await self._first_success("POST", ("control", "api/control", "v1/control"), json={"command": "volume", "set": pct}) - return data is not None + return False - async def playback_play(self) -> bool: - """RelayTV's preferred 'Play' semantics: POST /playback/play.""" - data = await self._first_success("POST", ("playback/play",), json={}) - if data is not None: + async def ensure_playing(self) -> bool: + """Best-effort play semantics for Home Assistant. + + RelayTV does not currently expose a single "resume session or play next" endpoint. + We emulate expected behavior: + + - If paused -> POST /resume + - Else if already playing -> success (noop) + - Else if queue has items -> POST /next + """ + st = await self.get_status() or {} + + if bool(st.get("paused")): + return await self.resume() + if bool(st.get("playing")): return True - # fallback - data = await self._first_success("POST", ("resume", "play"), json={}) - return data is not None - async def previous(self) -> bool: - """Go back (server decides restart vs history).""" - data = await self._first_success("POST", ("previous",), json={}) - return data is not None + try: + if int(st.get("queue_length") or 0) > 0: + return await self.next() + except Exception: + pass - async def play_now( - self, - url: str, - preserve_current: bool = True, - reason: Optional[str] = None, - title: Optional[str] = None, - thumbnail: Optional[str] = None, - ) -> bool: - """Interrupt-play a URL immediately, optionally preserving current into queue front.""" - payload: dict[str, Any] = { - "url": url, - "preserve_current": bool(preserve_current), - } - if reason: - payload["reason"] = reason - if title: - payload["title"] = title - if thumbnail: - payload["thumbnail"] = thumbnail - - data = await self._first_success("POST", ("play_now",), json=payload) - return data is not None + _LOGGER.debug("ensure_playing: nothing to resume or play") + return False diff --git a/custom_components/relaytv/services.yaml b/custom_components/relaytv/services.yaml index 24b77dc..19781ca 100644 --- a/custom_components/relaytv/services.yaml +++ b/custom_components/relaytv/services.yaml @@ -1,6 +1,6 @@ smart_url: name: Smart play URL - description: Resolve and play a shared URL in RelayTV (mobile share flow). + description: Resolve and play a shared URL in RelayTV (RelayTV POST /smart). fields: url: required: true @@ -9,36 +9,41 @@ smart_url: text: play_now: - name: Play now (interrupt) - description: Play a URL immediately and (optionally) preserve whatever is currently playing to the front of the queue. + name: Play now + description: Play a URL immediately (RelayTV POST /play). This clears the queue. fields: url: required: true example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" selector: text: - preserve_current: + use_ytdlp: required: false default: true selector: boolean: - reason: + cec: required: false - example: "announcement" + default: false selector: - text: + boolean: announce: - name: Announce (interrupt) - description: Convenience wrapper for play_now with reason=announcement. + name: Announce + description: Alias for play_now. (RelayTV does not currently implement a distinct announcement mode.) fields: url: required: true example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" selector: text: - preserve_current: + use_ytdlp: required: false default: true selector: boolean: + cec: + required: false + default: false + selector: + boolean: