Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add video event proxy endpoint for unifiprotect #129980

Merged
merged 13 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion homeassistant/components/unifiprotect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
async_create_api_client,
async_get_devices,
)
from .views import ThumbnailProxyView, VideoProxyView
from .views import ThumbnailProxyView, VideoEventProxyView, VideoProxyView

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -174,6 +174,7 @@ async def _async_setup_entry(
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
hass.http.register_view(ThumbnailProxyView(hass))
hass.http.register_view(VideoProxyView(hass))
hass.http.register_view(VideoEventProxyView(hass))


async def _async_options_updated(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
Expand Down
118 changes: 94 additions & 24 deletions homeassistant/components/unifiprotect/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from datetime import datetime
from http import HTTPStatus
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode

from aiohttp import web
Expand All @@ -30,7 +30,9 @@
) -> str:
"""Generate URL for event thumbnail."""

url_format = ThumbnailProxyView.url or "{nvr_id}/{event_id}"
url_format = ThumbnailProxyView.url
if TYPE_CHECKING:
assert url_format is not None
url = url_format.format(nvr_id=nvr_id, event_id=event_id)

params = {}
Expand All @@ -50,7 +52,9 @@
if event.start is None or event.end is None:
raise ValueError("Event is ongoing")

url_format = VideoProxyView.url or "{nvr_id}/{camera_id}/{start}/{end}"
url_format = VideoProxyView.url
if TYPE_CHECKING:
assert url_format is not None
return url_format.format(
nvr_id=event.api.bootstrap.nvr.id,
camera_id=event.camera_id,
Expand All @@ -59,6 +63,19 @@
)


@callback
def async_generate_proxy_event_video_url(
nvr_id: str,
event_id: str,
) -> str:
"""Generate proxy URL for event video."""

url_format = VideoEventProxyView.url
if TYPE_CHECKING:
assert url_format is not None
return url_format.format(nvr_id=nvr_id, event_id=event_id)


@callback
def _client_error(message: Any, code: HTTPStatus) -> web.Response:
_LOGGER.warning("Client error (%s): %s", code.value, message)
Expand Down Expand Up @@ -107,6 +124,27 @@
return data
return _404("Invalid NVR ID")

@callback
def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None:
if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None:
return camera

entity_registry = er.async_get(self.hass)
device_registry = dr.async_get(self.hass)

if (entity := entity_registry.async_get(camera_id)) is None or (
device := device_registry.async_get(entity.device_id or "")
) is None:
return None

macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC]
for mac in macs:
if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None:
if isinstance(ufp_device, Camera):
camera = ufp_device
break
return camera


class ThumbnailProxyView(ProtectProxyView):
"""View to proxy event thumbnails from UniFi Protect."""
Expand Down Expand Up @@ -156,27 +194,6 @@
url = "/api/unifiprotect/video/{nvr_id}/{camera_id}/{start}/{end}"
name = "api:unifiprotect_thumbnail"

@callback
def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None:
if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None:
return camera

entity_registry = er.async_get(self.hass)
device_registry = dr.async_get(self.hass)

if (entity := entity_registry.async_get(camera_id)) is None or (
device := device_registry.async_get(entity.device_id or "")
) is None:
return None

macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC]
for mac in macs:
if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None:
if isinstance(ufp_device, Camera):
camera = ufp_device
break
return camera

async def get(
self, request: web.Request, nvr_id: str, camera_id: str, start: str, end: str
) -> web.StreamResponse:
Expand Down Expand Up @@ -226,3 +243,56 @@
if response.prepared:
await response.write_eof()
return response


class VideoEventProxyView(ProtectProxyView):
"""View to proxy video clips for events from UniFi Protect."""

url = "/api/unifiprotect/video/{nvr_id}/{event_id}"
lutzvahl marked this conversation as resolved.
Show resolved Hide resolved
name = "api:unifiprotect_videoEventView"

async def get(
self, request: web.Request, nvr_id: str, event_id: str
) -> web.StreamResponse:
"""Get Camera Video clip for an event."""

data = self._get_data_or_404(nvr_id)
if isinstance(data, web.Response):
return data

try:
event = await data.api.get_event(event_id)
except ClientError:
return _404(f"Invalid event ID: {event_id}")
if event.start is None or event.end is None:
return _400("Event is still ongoing")
camera = self._async_get_camera(data, str(event.camera_id))
if camera is None:
return _404(f"Invalid camera ID: {event.camera_id}")
if not camera.can_read_media(data.api.bootstrap.auth_user):
return _403(f"User cannot read media from camera: {camera.id}")

Check warning on line 273 in homeassistant/components/unifiprotect/views.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/unifiprotect/views.py#L273

Added line #L273 was not covered by tests

response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": "video/mp4",
},
)

async def iterator(total: int, chunk: bytes | None) -> None:
if not response.prepared:
response.content_length = total
await response.prepare(request)

if chunk is not None:
await response.write(chunk)

try:
await camera.get_video(event.start, event.end, iterator_callback=iterator)
except ClientError as err:
return _404(err)

if response.prepared:
await response.write_eof()
lutzvahl marked this conversation as resolved.
Show resolved Hide resolved
return response
Loading
Loading