From 227ed7b8fc0219543114189097eab55571e20087 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 12:02:45 +0000 Subject: [PATCH] Add go2rtc HLS provider implementation Co-authored-by: edenhaus <26537646+edenhaus@users.noreply.github.com> --- homeassistant/components/camera/__init__.py | 17 +++ homeassistant/components/go2rtc/__init__.py | 160 +++++++++++++++++++- homeassistant/components/go2rtc/const.py | 3 + 3 files changed, 177 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 4286e7462cc..c893348920f 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -1085,6 +1085,23 @@ async def async_handle_play_stream_service( async def _async_stream_endpoint_url( hass: HomeAssistant, camera: Camera, fmt: str ) -> str: + # Check if go2rtc HLS provider is available and supports this camera + if fmt == "hls": + try: + from homeassistant.components.go2rtc.const import DOMAIN as GO2RTC_DOMAIN + if ( + GO2RTC_DOMAIN in hass.data + and "hls_provider" in hass.data[GO2RTC_DOMAIN] + ): + hls_provider = hass.data[GO2RTC_DOMAIN]["hls_provider"] + # Check if camera stream source is compatible with go2rtc + if await camera.stream_source(): + if hls_provider.async_is_supported(await camera.stream_source()): + return await hls_provider.async_get_stream_url(camera) + except Exception: + # If go2rtc HLS fails, fall back to stream integration + pass + stream = await camera.async_create_stream() if not stream: raise HomeAssistantError( diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index aeedb847090..7d2789009a3 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging import shutil -from aiohttp import ClientSession +from aiohttp import ClientSession, web from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from awesomeversion import AwesomeVersion from go2rtc_client import Go2RtcRestClient @@ -31,6 +31,7 @@ from homeassistant.components.camera import ( WebRTCSendMessage, async_register_webrtc_provider, ) +from homeassistant.components.http import HomeAssistantView from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP @@ -50,6 +51,7 @@ from .const import ( CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, + GO2RTC_HLS_PROVIDER, HA_MANAGED_URL, RECOMMENDED_VERSION, ) @@ -193,14 +195,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False - provider = entry.runtime_data = WebRTCProvider(hass, url, session, client) - entry.async_on_unload(async_register_webrtc_provider(hass, provider)) + webrtc_provider = WebRTCProvider(hass, url, session, client) + hls_provider = Go2RtcHlsProvider(hass, url, session, client) + + # Set up HLS provider + await hls_provider.async_setup() + + # Store both providers in runtime_data + entry.runtime_data = webrtc_provider + entry.async_on_unload(async_register_webrtc_provider(hass, webrtc_provider)) + + # Store HLS provider for access in other parts of the integration + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN]["hls_provider"] = hls_provider + return True async def async_unload_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool: """Unload a go2rtc config entry.""" await entry.runtime_data.teardown() + + # Clean up HLS provider + if DOMAIN in hass.data and "hls_provider" in hass.data[DOMAIN]: + hls_provider = hass.data[DOMAIN]["hls_provider"] + await hls_provider.async_teardown() + del hass.data[DOMAIN]["hls_provider"] + return True @@ -336,3 +357,136 @@ class WebRTCProvider(CameraWebRTCProvider): for ws_client in self._sessions.values(): await ws_client.close() self._sessions.clear() + + +class Go2RtcHlsView(HomeAssistantView): + """View to proxy HLS requests to go2rtc server.""" + + url = r"/api/go2rtc_hls/{entity_id}/{file_name:.*}" + name = "api:go2rtc:hls" + requires_auth = False + + def __init__(self, hass: HomeAssistant, go2rtc_url: str) -> None: + """Initialize the view.""" + self.hass = hass + self.go2rtc_url = go2rtc_url.rstrip('/') + + async def get(self, request: web.Request, entity_id: str, file_name: str) -> web.Response: + """Proxy HLS requests to go2rtc server.""" + # Validate entity_id exists and is accessible + if entity_id not in self.hass.states.async_entity_ids("camera"): + raise web.HTTPNotFound() + + # Proxy request to go2rtc server + # go2rtc uses stream.m3u8?src=entity_id format + if file_name == "playlist.m3u8": + url = f"{self.go2rtc_url}/api/stream.m3u8" + params = {"src": entity_id} + else: + # For segment files, proxy directly + url = f"{self.go2rtc_url}/api/{file_name}" + params = {"src": entity_id} + + params.update(request.query) + + from homeassistant.helpers.aiohttp_client import async_get_clientsession + session = async_get_clientsession(self.hass) + + try: + async with session.get(url, params=params) as resp: + if resp.status != 200: + raise web.HTTPNotFound() + + content_type = resp.headers.get('Content-Type', 'application/vnd.apple.mpegurl') + body = await resp.read() + + # For playlist files, we need to rewrite segment URLs to point to our proxy + if file_name == "playlist.m3u8" and content_type.startswith('application/'): + content = body.decode('utf-8') + # Rewrite segment URLs to use our proxy + lines = content.split('\n') + for i, line in enumerate(lines): + if line and not line.startswith('#') and not line.startswith('http'): + # This is a segment reference, rewrite it to use our proxy + lines[i] = f"/api/go2rtc_hls/{entity_id}/{line}" + body = '\n'.join(lines).encode('utf-8') + + return web.Response( + body=body, + content_type=content_type, + headers={'Access-Control-Allow-Origin': '*'} + ) + except Exception as err: + _LOGGER.error("Error proxying HLS request to go2rtc: %s", err) + raise web.HTTPInternalServerError() from err + + +class Go2RtcHlsProvider: + """Go2rtc HLS provider for camera streaming.""" + + def __init__( + self, + hass: HomeAssistant, + url: str, + session: ClientSession, + rest_client: Go2RtcRestClient, + ) -> None: + """Initialize the HLS provider.""" + self._hass = hass + self._url = url + self._session = session + self._rest_client = rest_client + self._view: Go2RtcHlsView | None = None + + async def async_setup(self) -> None: + """Set up the HLS provider.""" + # Register the HLS view for proxying requests + self._view = Go2RtcHlsView(self._hass, self._url) + self._hass.http.register_view(self._view) + + async def async_teardown(self) -> None: + """Tear down the HLS provider.""" + # View cleanup is handled by Home Assistant + pass + + @callback + def async_is_supported(self, stream_source: str) -> bool: + """Return if this provider supports the camera stream source.""" + return stream_source.partition(":")[0] in _SUPPORTED_STREAMS + + async def async_get_stream_url(self, camera: Camera) -> str: + """Get HLS stream URL for the camera.""" + # Ensure stream is configured in go2rtc + await self._update_stream_source(camera) + + # Return the HLS playlist URL through our proxy + return f"/api/go2rtc_hls/{camera.entity_id}/playlist.m3u8" + + async def _update_stream_source(self, camera: Camera) -> None: + """Update the stream source in go2rtc config if needed.""" + if not (stream_source := await camera.stream_source()): + raise HomeAssistantError("Camera has no stream source") + + if camera.platform.platform_name == "generic": + # This is a workaround to use ffmpeg for generic cameras + # A proper fix will be added in the future together with supporting multiple streams per camera + stream_source = "ffmpeg:" + stream_source + + if not self.async_is_supported(stream_source): + raise HomeAssistantError("Stream source is not supported by go2rtc") + + streams = await self._rest_client.streams.list() + + if (stream := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream.producers + ): + await self._rest_client.streams.add( + camera.entity_id, + [ + stream_source, + # We are setting any ffmpeg rtsp related logs to debug + # Connection problems to the camera will be logged by the first stream + # Therefore setting it to debug will not hide any important logs + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], + ) diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 491b2269043..081a727f5f6 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -7,3 +7,6 @@ DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" RECOMMENDED_VERSION = "1.9.9" + +# HLS provider constants +GO2RTC_HLS_PROVIDER = "go2rtc_hls"