From b9bdcf3647b63e4303876235f165e2f6f5e958d5 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 27 Nov 2021 11:51:22 -0800 Subject: [PATCH] Fixes to support in-progress events (Frigate 0.10+) (#178) --- custom_components/frigate/media_source.py | 33 +++-- custom_components/frigate/views.py | 36 +++--- tests/test_media_source.py | 139 +++++++++++++++++++++- tests/test_views.py | 18 ++- 4 files changed, 181 insertions(+), 45 deletions(-) diff --git a/custom_components/frigate/media_source.py b/custom_components/frigate/media_source.py index 3f836133..cda09971 100644 --- a/custom_components/frigate/media_source.py +++ b/custom_components/frigate/media_source.py @@ -88,8 +88,8 @@ def get_identifier_type(cls) -> str: """Get the identifier type.""" raise NotImplementedError - def get_frigate_server_path(self) -> str: - """Get the equivalent Frigate server path.""" + def get_integration_proxy_path(self) -> str: + """Get the proxy (Home Assistant view) path for this identifier.""" raise NotImplementedError @classmethod @@ -214,12 +214,12 @@ def get_identifier_type(cls) -> str: """Get the identifier type.""" return "event" - def get_frigate_server_path(self) -> str: + def get_integration_proxy_path(self) -> str: """Get the equivalent Frigate server path.""" if self.frigate_media_type == FrigateMediaType.CLIPS: return f"vod/event/{self.id}/index.{self.frigate_media_type.extension}" else: - return f"clips/{self.camera}-{self.id}.{self.frigate_media_type.extension}" + return f"snapshot/{self.id}" @property def mime_type(self) -> str: @@ -433,8 +433,8 @@ def get_identifier_type(cls) -> str: """Get the identifier type.""" return "recordings" - def get_frigate_server_path(self) -> str: - """Get the equivalent Frigate server path.""" + def get_integration_proxy_path(self) -> str: + """Get the integration path that will proxy this identifier.""" # The attributes of this class represent a path that the recording can # be retrieved from the Frigate server. If there are holes in the path @@ -545,7 +545,7 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: default_frigate_instance_id=self._get_default_frigate_instance_id(), ) if identifier: - server_path = identifier.get_frigate_server_path() + server_path = identifier.get_integration_proxy_path() return PlayMedia( f"/api/frigate/{identifier.frigate_instance_id}/{server_path}", identifier.mime_type, @@ -654,7 +654,7 @@ async def async_browse_media( ) if isinstance(identifier, RecordingIdentifier): - path = identifier.get_frigate_server_path() + path = identifier.get_integration_proxy_path() try: recordings_folder = await self._get_client(identifier).async_get_path( path @@ -781,6 +781,21 @@ def _build_event_response( ) -> BrowseMediaSource: children = [] for event in events: + start_time = event.get("start_time") + end_time = event.get("end_time") + if start_time is None: + continue + + if end_time is None: + # Events that are in progress will not yet have an end_time, so + # the duration is shown as the current time minus the start + # time. + duration = int( + dt.datetime.now(DEFAULT_TIME_ZONE).timestamp() - start_time + ) + else: + duration = int(end_time - start_time) + children.append( BrowseMediaSource( domain=DOMAIN, @@ -792,7 +807,7 @@ def _build_event_response( ), media_class=identifier.media_class, media_content_type=identifier.media_type, - title=f"{dt.datetime.fromtimestamp(event['start_time'], DEFAULT_TIME_ZONE).strftime(DATE_STR_FORMAT)} [{int(event['end_time']-event['start_time'])}s, {event['label'].capitalize()} {int(event['top_score']*100)}%]", + title=f"{dt.datetime.fromtimestamp(event['start_time'], DEFAULT_TIME_ZONE).strftime(DATE_STR_FORMAT)} [{duration}s, {event['label'].capitalize()} {int(event['top_score']*100)}%]", can_play=identifier.media_type == MEDIA_TYPE_VIDEO, can_expand=False, thumbnail=f"data:image/jpeg;base64,{event['thumbnail']}", diff --git a/custom_components/frigate/views.py b/custom_components/frigate/views.py index a09e3034..48835bca 100644 --- a/custom_components/frigate/views.py +++ b/custom_components/frigate/views.py @@ -100,7 +100,7 @@ def _get_config_entry_for_request( return get_config_entry_for_frigate_instance_id(hass, frigate_instance_id) return get_default_config_entry(hass) - def _create_path(self, path: str, **kwargs: Any) -> str | None: + def _create_path(self, **kwargs: Any) -> str | None: """Create path.""" raise NotImplementedError # pragma: no cover @@ -125,7 +125,6 @@ async def get( async def _handle_request( self, request: web.Request, - path: str, frigate_instance_id: str | None = None, **kwargs: Any, ) -> web.Response | web.StreamResponse: @@ -137,7 +136,7 @@ async def _handle_request( if not self._permit_request(request, config_entry): return web.Response(status=HTTPStatus.FORBIDDEN) - full_path = self._create_path(path=path, **kwargs) + full_path = self._create_path(**kwargs) if not full_path: return web.Response(status=HTTPStatus.NOT_FOUND) @@ -176,14 +175,14 @@ async def _handle_request( class SnapshotsProxyView(ProxyView): """A proxy for snapshots.""" - url = "/api/frigate/{frigate_instance_id:.+}/clips/{path:.*}" - extra_urls = ["/api/frigate/clips/{path:.*}"] + url = "/api/frigate/{frigate_instance_id:.+}/snapshot/{eventid:.*}" + extra_urls = ["/api/frigate/snapshot/{eventid:.*}"] name = "api:frigate:snapshots" - def _create_path(self, path: str, **kwargs: Any) -> str: + def _create_path(self, **kwargs: Any) -> str | None: """Create path.""" - return f"clips/{path}" + return f"api/events/{kwargs['eventid']}/snapshot.jpg" class NotificationsProxyView(ProxyView): @@ -195,9 +194,9 @@ class NotificationsProxyView(ProxyView): name = "api:frigate:notification" requires_auth = False - def _create_path(self, path: str, **kwargs: Any) -> str | None: + def _create_path(self, **kwargs: Any) -> str | None: """Create path.""" - event_id = kwargs["event_id"] + path, event_id = kwargs["path"], kwargs["event_id"] if path == "thumbnail.jpg": return f"api/events/{event_id}/thumbnail.jpg" @@ -221,11 +220,9 @@ class VodProxyView(ProxyView): name = "api:frigate:vod:mainfest" - def _create_path(self, path: str, **kwargs: Any) -> str | None: + def _create_path(self, **kwargs: Any) -> str | None: """Create path.""" - manifest = kwargs["manifest"] - - return f"vod/{path}/{manifest}.m3u8" + return f"vod/{kwargs['path']}/{kwargs['manifest']}.m3u8" class VodSegmentProxyView(ProxyView): @@ -237,11 +234,9 @@ class VodSegmentProxyView(ProxyView): name = "api:frigate:vod:segment" requires_auth = False - def _create_path(self, path: str, **kwargs: Any) -> str | None: + def _create_path(self, **kwargs: Any) -> str | None: """Create path.""" - segment = kwargs["segment"] - - return f"vod/{path}/{segment}.ts" + return f"vod/{kwargs['path']}/{kwargs['segment']}.ts" async def _async_validate_signed_manifest(self, request: web.Request) -> bool: """Validate the signature for the manifest of this segment.""" @@ -307,7 +302,6 @@ async def _proxy_msgs( async def _handle_request( self, request: web.Request, - path: str, frigate_instance_id: str | None = None, **kwargs: Any, ) -> web.Response | web.StreamResponse: @@ -320,7 +314,7 @@ async def _handle_request( if not self._permit_request(request, config_entry): return web.Response(status=HTTPStatus.FORBIDDEN) - full_path = self._create_path(path=path, **kwargs) + full_path = self._create_path(**kwargs) if not full_path: return web.Response(status=HTTPStatus.NOT_FOUND) @@ -369,9 +363,9 @@ class JSMPEGProxyView(WebsocketProxyView): name = "api:frigate:jsmpeg" - def _create_path(self, path: str, **kwargs: Any) -> str | None: + def _create_path(self, **kwargs: Any) -> str | None: """Create path.""" - return f"live/{path}" + return f"live/{kwargs['path']}" def _init_header(request: web.Request) -> CIMultiDict | dict[str, str]: diff --git a/tests/test_media_source.py b/tests/test_media_source.py index e81450b9..a9ce8197 100644 --- a/tests/test_media_source.py +++ b/tests/test_media_source.py @@ -45,7 +45,9 @@ def _get_fixed_datetime() -> datetime.datetime: """Get a fixed-in-time datetime.""" datetime_today = Mock(wraps=datetime.datetime) datetime_today.now = Mock( - return_value=datetime.datetime(2021, 6, 4, 0, 0, tzinfo=datetime.timezone.utc) + return_value=datetime.datetime( + 2021, 6, 4, 0, 0, 30, tzinfo=datetime.timezone.utc + ) ) return datetime_today @@ -605,11 +607,11 @@ async def test_async_resolve_media( hass, ( f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" - "/event/snapshots/camera/snapshot" + "/event/snapshots/camera/event_id" ), ) assert media == PlayMedia( - url=f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/clips/camera-snapshot.jpg", + url=f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/snapshot/event_id", mime_type="image/jpg", ) @@ -1005,7 +1007,7 @@ async def test_event_search_identifier() -> None: # Event searches have no equivalent Frigate server path (searches result in # EventIdentifiers, that do have a Frigate server path). with pytest.raises(NotImplementedError): - identifier.get_frigate_server_path() + identifier.get_integration_proxy_path() # Invalid "after" time. assert ( @@ -1102,7 +1104,7 @@ async def test_recordings_identifier() -> None: identifier_in = f"{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-06/04//front_door" identifier = RecordingIdentifier.from_str(identifier_in) assert identifier - assert identifier.get_frigate_server_path() == "vod/2021-06/04" + assert identifier.get_integration_proxy_path() == "vod/2021-06/04" # Verify a zero hour: # https://github.com/blakeblackshear/frigate-hass-integration/issues/126 @@ -1265,3 +1267,130 @@ async def test_snapshots(hass: HomeAssistant) -> None: limit=50, has_snapshot=True, ) + + +async def test_media_types() -> None: + """Test FrigateMediaTypes.""" + snapshots = FrigateMediaType("snapshots") + assert snapshots.mime_type == "image/jpg" + assert snapshots.media_class == "image" + assert snapshots.media_type == "image" + assert snapshots.extension == "jpg" + + clips = FrigateMediaType("clips") + assert clips.mime_type == "application/x-mpegURL" + assert clips.media_class == "video" + assert clips.media_type == "video" + assert clips.extension == "m3u8" + + +@patch("custom_components.frigate.media_source.dt.datetime", new=TODAY) +async def test_in_progress_event(hass: HomeAssistant) -> None: + """Verify in progress events are handled correctly.""" + client = create_mock_frigate_client() + client.async_get_event_summary = AsyncMock( + return_value=[ + { + "camera": "front_door", + "count": 1, + "day": "2021-06-04", + "label": "person", + "zones": [], + } + ] + ) + client.async_get_events = AsyncMock( + return_value=[ + { + "camera": "front_door", + # Event has not yet ended: + "end_time": None, + "false_positive": False, + "has_clip": True, + "has_snapshot": True, + "id": "1622764820.555377-55xy6j", + "label": "person", + # This is 10s before the value of TODAY: + "start_time": 1622764820.0, + "top_score": 0.7265625, + "zones": [], + "thumbnail": "thumbnail", + } + ] + ) + await setup_mock_frigate_config_entry(hass, client=client) + + media = await media_source.async_browse_media( + hass, + ( + f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" + "/event-search/snapshots/.this_month.2021-06-04.front_door.person" + "/1622764800/1622851200/front_door/person/" + ), + ) + + assert len(media.as_dict()["children"]) == 1 + + assert media.as_dict() == { + "media_content_id": ( + f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}/event-search" + "/snapshots/.this_month.2021-06-04.front_door.person/1622764800" + "/1622851200/front_door/person/" + ), + "title": "This Month > 2021-06-04 > Front Door > Person (1)", + "media_class": "directory", + "media_content_type": "image", + "can_play": False, + "can_expand": True, + "children_media_class": "image", + "thumbnail": None, + "children": [ + { + # Duration will be shown as 10s, since 10s has elapsed since + # this event started. + "title": "2021-06-04 00:00:20 [10s, Person 72%]", + "media_class": "image", + "media_content_type": "image", + "media_content_id": "media-source://frigate/frigate_client_id/event/snapshots/front_door/1622764820.555377-55xy6j", + "can_play": False, + "can_expand": False, + "children_media_class": None, + "thumbnail": "data:image/jpeg;base64,thumbnail", + } + ], + } + + +async def test_bad_event(hass: HomeAssistant) -> None: + """Verify malformed events are handled correctly.""" + client = create_mock_frigate_client() + client.async_get_events = AsyncMock( + return_value=[ + { + "camera": "front_door", + "end_time": None, + # Events without a start_time are skipped. + "start_time": None, + "false_positive": False, + "has_clip": True, + "has_snapshot": True, + "id": "1622764820.555377-55xy6j", + "label": "person", + "top_score": 0.7265625, + "zones": [], + "thumbnail": "thumbnail", + } + ] + ) + await setup_mock_frigate_config_entry(hass, client=client) + + media = await media_source.async_browse_media( + hass, + ( + f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" + "/event-search/snapshots/.this_month.2021-06-04.front_door.person" + "/1622764800/1622851200/front_door/person/" + ), + ) + + assert len(media.as_dict()["children"]) == 0 diff --git a/tests/test_views.py b/tests/test_views.py index c20a24d7..79343981 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -143,7 +143,6 @@ async def handler(request: web.Request) -> web.Response: server = await start_frigate_server( aiohttp_server, [ - web.get("/clips/present", handler), web.get("/vod/present/manifest.m3u8", handler), web.get("/vod/present/segment.ts", handler), web.get("/api/events/event_id/thumbnail.jpg", handler), @@ -238,11 +237,10 @@ async def test_snapshot_proxy_view_success( hass_client_local_frigate: Any, ) -> None: """Test straightforward snapshot requests.""" - - resp = await hass_client_local_frigate.get("/api/frigate/clips/present") + resp = await hass_client_local_frigate.get("/api/frigate/snapshot/event_id") assert resp.status == HTTPStatus.OK - resp = await hass_client_local_frigate.get("/api/frigate/clips/not_present") + resp = await hass_client_local_frigate.get("/api/frigate/snapshot/not_present") assert resp.status == HTTPStatus.NOT_FOUND @@ -255,7 +253,7 @@ async def test_snapshot_proxy_view_write_error( "custom_components.frigate.views.web.StreamResponse", new=ClientErrorStreamResponse, ): - await hass_client_local_frigate.get("/api/frigate/clips/present") + await hass_client_local_frigate.get("/api/frigate/snapshot/event_id") assert "Stream error" in caplog.text @@ -268,7 +266,7 @@ async def test_snapshot_proxy_view_connection_reset( "custom_components.frigate.views.web.StreamResponse", new=ConnectionResetStreamResponse, ): - await hass_client_local_frigate.get("/api/frigate/clips/present") + await hass_client_local_frigate.get("/api/frigate/snapshot/event_id") assert "Stream error" not in caplog.text @@ -285,7 +283,7 @@ async def test_snapshot_proxy_view_read_error( "request", new=mock_request, ): - await hass_client_local_frigate.get("/api/frigate/clips/present") + await hass_client_local_frigate.get("/api/frigate/snapshot/event_id") assert "Reverse proxy error" in caplog.text @@ -363,19 +361,19 @@ async def test_snapshots_with_frigate_instance_id( # A Frigate instance id is specified. resp = await hass_client_local_frigate.get( - f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/clips/present" + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/snapshot/event_id" ) assert resp.status == HTTPStatus.OK # An invalid instance id is specified. resp = await hass_client_local_frigate.get( - "/api/frigate/NOT_A_REAL_ID/clips/present" + "/api/frigate/NOT_A_REAL_ID/snapshot/event_id" ) assert resp.status == HTTPStatus.BAD_REQUEST # No default allowed when there are multiple entries. create_mock_frigate_config_entry(hass, entry_id="another_id") - resp = await hass_client_local_frigate.get("/api/frigate/clips/present") + resp = await hass_client_local_frigate.get("/api/frigate/snapshot/event_id") assert resp.status == HTTPStatus.BAD_REQUEST