Skip to content

Commit

Permalink
Fixes to support in-progress events (Frigate 0.10+) (#178)
Browse files Browse the repository at this point in the history
  • Loading branch information
dermotduffy committed Nov 27, 2021
1 parent d06b3a0 commit b9bdcf3
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 45 deletions.
33 changes: 24 additions & 9 deletions custom_components/frigate/media_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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']}",
Expand Down
36 changes: 15 additions & 21 deletions custom_components/frigate/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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):
Expand All @@ -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"

Expand All @@ -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):
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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]:
Expand Down
139 changes: 134 additions & 5 deletions tests/test_media_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
)

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading

0 comments on commit b9bdcf3

Please sign in to comment.