1 """UniFi Protect media sources."""
3 from __future__
import annotations
6 from datetime
import date, datetime, timedelta
8 from typing
import Any, NoReturn, cast
10 from uiprotect.data
import Camera, Event, EventType, SmartDetectObjectType
11 from uiprotect.exceptions
import NvrError
12 from uiprotect.utils
import from_js_time
28 from .const
import DOMAIN
29 from .data
import ProtectData, async_get_ufp_entries
30 from .views
import async_generate_event_video_url, async_generate_thumbnail_url
32 VIDEO_FORMAT =
"video/mp4"
34 THUMBNAIL_HEIGHT = 185
38 """Enum to Camera Video events."""
48 """UniFi Protect identifier type."""
51 EVENT_THUMB =
"eventthumb"
56 """UniFi Protect identifier subtype."""
62 EVENT_MAP: dict[SimpleEventType, set[EventType]] = {
63 SimpleEventType.ALL: {
66 EventType.SMART_DETECT,
67 EventType.SMART_DETECT_LINE,
68 EventType.SMART_AUDIO_DETECT,
70 SimpleEventType.RING: {EventType.RING},
71 SimpleEventType.MOTION: {EventType.MOTION},
72 SimpleEventType.SMART: {EventType.SMART_DETECT, EventType.SMART_DETECT_LINE},
73 SimpleEventType.AUDIO: {EventType.SMART_AUDIO_DETECT},
76 SimpleEventType.ALL:
"All Events",
77 SimpleEventType.RING:
"Ring Events",
78 SimpleEventType.MOTION:
"Motion Events",
79 SimpleEventType.SMART:
"Object Detections",
80 SimpleEventType.AUDIO:
"Audio Detections",
85 """Set up UniFi Protect media source."""
89 entry.runtime_data.api.bootstrap.nvr.id: entry.runtime_data
97 start = dt_util.as_local(start)
100 start = start.replace(day=1, hour=0, minute=0, second=1, microsecond=0)
101 end = end.replace(day=1, hour=0, minute=0, second=2, microsecond=0)
108 msg = f
"Unexpected identifier: {identifier}"
117 seconds =
int(duration.total_seconds())
119 hours = seconds // 3600
120 formatted += f
"{hours}h "
121 seconds -= hours * 3600
123 minutes = seconds // 60
124 formatted += f
"{minutes}m "
125 seconds -= minutes * 60
127 formatted += f
"{seconds}s "
129 return formatted.strip()
134 if isinstance(event, Event):
135 event = event.unifi_dict()
138 types = set(event[
"smartDetectTypes"])
139 metadata = event.get(
"metadata")
or {}
140 for thumb
in metadata.get(
"detectedThumbnails", []):
141 thumb_type = thumb.get(
"type")
142 if thumb_type
not in types:
145 types.remove(thumb_type)
146 if thumb_type == SmartDetectObjectType.VEHICLE.value:
147 attributes = thumb.get(
"attributes")
or {}
148 color = attributes.get(
"color", {}).
get(
"val",
"")
149 vehicle_type = attributes.get(
"vehicleType", {}).
get(
"val",
"vehicle")
150 license_plate = metadata.get(
"licensePlate", {}).
get(
"name")
152 name = f
"{color} {vehicle_type}".strip().title()
154 types.remove(SmartDetectObjectType.LICENSE_PLATE.value)
155 name = f
"{name}: {license_plate}"
158 smart_type = SmartDetectObjectType(thumb_type)
159 names.append(smart_type.name.title().replace(
"_",
" "))
162 smart_type = SmartDetectObjectType(raw)
163 names.append(smart_type.name.title().replace(
"_",
" "))
165 return ", ".join(sorted(names))
170 if isinstance(event, Event):
171 event = event.unifi_dict()
173 smart_types = [SmartDetectObjectType(e)
for e
in event[
"smartDetectTypes"]]
174 return ", ".join([s.name.title().replace(
"_",
" ")
for s
in smart_types])
178 """Represents all UniFi Protect NVRs."""
180 name: str =
"UniFi Protect"
181 _registry: er.EntityRegistry |
None
184 self, hass: HomeAssistant, data_sources: dict[str, ProtectData]
186 """Initialize the UniFi Protect media source."""
194 """Return a streamable URL and associated mime type for a UniFi Protect event.
196 Accepted identifier format are
198 * {nvr_id}:event:{event_id} - MP4 video clip for specific event
199 * {nvr_id}:eventthumb:{event_id} - Thumbnail JPEG for specific event
202 parts = item.identifier.split(
":")
203 if len(parts) != 3
or parts[1]
not in (
"event",
"eventthumb"):
206 thumbnail_only = parts[1] ==
"eventthumb"
209 except (KeyError, IndexError)
as err:
212 event = data.api.bootstrap.events.get(parts[2])
215 event = await data.api.get_event(parts[2])
216 except NvrError
as err:
220 data.api.bootstrap.events[event.id] = event
222 nvr = data.api.bootstrap.nvr
230 """Return a browsable UniFi Protect media source.
232 Identifier formatters for UniFi Protect media sources are all in IDs from
233 the UniFi Protect instance since events may not always map 1:1 to a Home
234 Assistant device or entity. It also drasically speeds up resolution.
236 The UniFi Protect Media source is timebased for the events recorded by the NVR.
237 So its structure is a bit different then many other media players. All browsable
238 media is a video clip. The media source could be greatly cleaned up if/when the
239 frontend has filtering supporting.
241 * ... Each NVR Console (hidden if there is only one)
246 * Last 24 Hours -> Events
247 * Last 7 Days -> Events
248 * Last 30 Days -> Events
250 * Whole Month -> Events
251 * ... Day X -> Events
253 Accepted identifier formats:
255 * {nvr_id}:event:{event_id}
256 Specific Event for NVR
257 * {nvr_id}:eventthumb:{event_id}
258 Specific Event Thumbnail for NVR
260 Root NVR browse source
261 * {nvr_id}:browse:all|{camera_id}
262 Root Camera(s) browse source
263 * {nvr_id}:browse:all|{camera_id}:all|{event_type}
264 Root Camera(s) Event Type(s) browse source
265 * {nvr_id}:browse:all|{camera_id}:all|{event_type}:recent:{day_count}
266 Listing of all events in last {day_count}, sorted in reverse chronological order
267 * {nvr_id}:browse:all|{camera_id}:all|{event_type}:range:{year}:{month}
268 List of folders for each day in month + all events for month
269 * {nvr_id}:browse:all|{camera_id}:all|{event_type}:range:{year}:{month}:all|{day}
270 Listing of all events for give {day} + {month} + {year} combination in chronological order
273 if not item.identifier:
276 parts = item.identifier.split(
":")
280 except (KeyError, IndexError)
as err:
288 except ValueError
as err:
291 if identifier_type
in (IdentifierType.EVENT, IdentifierType.EVENT_THUMB):
292 thumbnail_only = identifier_type == IdentifierType.EVENT_THUMB
293 return await self.
_resolve_event_resolve_event(data, parts[2], thumbnail_only)
303 camera_id = parts.pop(0)
305 return await self.
_build_camera_build_camera(data, camera_id, build_children=
True)
310 except (IndexError, ValueError)
as err:
315 data, camera_id, event_type, build_children=
True
320 except ValueError
as err:
327 if time_type == IdentifierTimeType.RECENT:
329 days =
int(parts.pop(0))
330 except (IndexError, ValueError)
as err:
334 data, camera_id, event_type, days, build_children=
True
340 start, is_month, is_all = self.
_parse_range_parse_range(parts)
341 except (IndexError, ValueError)
as err:
346 data, camera_id, event_type, start, build_children=
True
349 data, camera_id, event_type, start, build_children=
True, is_all=is_all
357 month =
int(parts[1])
360 if parts[2] !=
"all":
364 start =
date(year=year, month=month, day=day)
365 return start, is_month, is_all
368 self, data: ProtectData, event_id: str, thumbnail_only: bool =
False
369 ) -> BrowseMediaSource:
370 """Resolve a specific event."""
372 subtype =
"eventthumb" if thumbnail_only
else "event"
374 event = await data.api.get_event(event_id)
375 except NvrError
as err:
376 _bad_identifier(f
"{data.api.bootstrap.nvr.id}:{subtype}:{event_id}", err)
378 if event.start
is None or event.end
is None:
381 return await self.
_build_event_build_event(data, event, thumbnail_only)
385 """Get or return Entity Registry."""
394 camera: Camera |
None =
None,
395 event_type: SimpleEventType |
None =
None,
396 count: int |
None =
None,
399 if count
is not None:
400 if count == data.max_events:
401 title = f
"{title} ({count} TRUNCATED)"
403 title = f
"{title} ({count})"
405 if event_type
is not None:
406 title = f
"{EVENT_NAME_MAP[event_type].title()} > {title}"
408 if camera
is not None:
409 title = f
"{camera.display_name} > {title}"
410 return f
"{data.api.bootstrap.nvr.display_name} > {title}"
415 event: dict[str, Any] | Event,
416 thumbnail_only: bool =
False,
417 ) -> BrowseMediaSource:
418 """Build media source for an individual event."""
420 if isinstance(event, Event):
422 event_type = event.type
426 event_id = event[
"id"]
427 event_type = EventType(event[
"type"])
428 start = from_js_time(event[
"start"])
429 end = from_js_time(event[
"end"])
431 assert end
is not None
433 title = dt_util.as_local(start).strftime(
"%x %X")
434 duration = end - start
435 title += f
" {_format_duration(duration)}"
436 if event_type
in EVENT_MAP[SimpleEventType.RING]:
437 event_text =
"Ring Event"
438 elif event_type
in EVENT_MAP[SimpleEventType.MOTION]:
439 event_text =
"Motion Event"
440 elif event_type
in EVENT_MAP[SimpleEventType.SMART]:
441 event_text = f
"Object Detection - {_get_object_name(event)}"
442 elif event_type
in EVENT_MAP[SimpleEventType.AUDIO]:
443 event_text = f
"Audio Detection - {_get_audio_name(event)}"
444 title += f
" {event_text}"
446 nvr = data.api.bootstrap.nvr
450 identifier=f
"{nvr.id}:eventthumb:{event_id}",
451 media_class=MediaClass.IMAGE,
452 media_content_type=
"image/jpeg",
457 event_id, nvr.id, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT
463 identifier=f
"{nvr.id}:event:{event_id}",
464 media_class=MediaClass.VIDEO,
465 media_content_type=
"video/mp4",
470 event_id, nvr.id, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT
479 camera_id: str |
None =
None,
480 event_types: set[EventType] |
None =
None,
481 reserve: bool =
False,
482 ) -> list[BrowseMediaSource]:
483 """Build media source for a given range of time and event type."""
485 event_types = event_types
or EVENT_MAP[SimpleEventType.ALL]
486 types =
list(event_types)
487 sources: list[BrowseMediaSource] = []
488 events = await data.api.get_events_raw(
489 start=start, end=end, types=types, limit=data.max_events
491 events = sorted(events, key=
lambda e: cast(int, e[
"start"]), reverse=reserve)
494 if event.get(
"start")
is None or event.get(
"end")
is None:
497 if camera_id
is not None and event.get(
"camera") != camera_id:
501 if event.get(
"type") == EventType.MOTION.value
and event.get(
506 sources.append(await self.
_build_event_build_event(data, event))
514 event_type: SimpleEventType,
516 build_children: bool =
False,
517 ) -> BrowseMediaSource:
518 """Build media source for events in relative days."""
520 base_id = f
"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
521 title = f
"Last {days} Days"
523 title =
"Last 24 Hours"
527 identifier=f
"{base_id}:recent:{days}",
528 media_class=MediaClass.DIRECTORY,
529 media_content_type=
"video/mp4",
533 children_media_class=MediaClass.VIDEO,
536 if not build_children:
540 camera: Camera |
None =
None
541 event_camera_id: str |
None =
None
542 if camera_id !=
"all":
543 camera = data.api.bootstrap.cameras.get(camera_id)
544 event_camera_id = camera_id
550 camera_id=event_camera_id,
551 event_types=EVENT_MAP[event_type],
554 source.children = events
559 event_type=event_type,
568 event_type: SimpleEventType,
570 build_children: bool =
False,
571 ) -> BrowseMediaSource:
572 """Build media source for selectors for a given month."""
574 base_id = f
"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
576 title = f
"{start.strftime('%B %Y')}"
579 identifier=f
"{base_id}:range:{start.year}:{start.month}",
580 media_class=MediaClass.DIRECTORY,
581 media_content_type=VIDEO_FORMAT,
585 children_media_class=MediaClass.VIDEO,
588 if not build_children:
591 if data.api.bootstrap.recording_start
is not None:
592 recording_start = data.api.bootstrap.recording_start.date()
593 start =
max(recording_start, start)
595 recording_end = dt_util.now().
date()
596 end = start.replace(month=start.month + 1) -
timedelta(days=1)
597 end =
min(recording_end, end)
599 children = [self.
_build_days_build_days(data, camera_id, event_type, start, is_all=
True)]
602 self.
_build_days_build_days(data, camera_id, event_type, start, is_all=
False)
606 camera: Camera |
None =
None
607 if camera_id !=
"all":
608 camera = data.api.bootstrap.cameras.get(camera_id)
610 source.children = await asyncio.gather(*children)
615 event_type=event_type,
624 event_type: SimpleEventType,
627 build_children: bool =
False,
628 ) -> BrowseMediaSource:
629 """Build media source for events for a given day or whole month."""
631 base_id = f
"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
634 title =
"Whole Month"
635 identifier = f
"{base_id}:range:{start.year}:{start.month}:all"
637 title = f
"{start.strftime('%x')}"
638 identifier = f
"{base_id}:range:{start.year}:{start.month}:{start.day}"
641 identifier=identifier,
642 media_class=MediaClass.DIRECTORY,
643 media_content_type=VIDEO_FORMAT,
647 children_media_class=MediaClass.VIDEO,
650 if not build_children:
660 tzinfo=dt_util.get_default_time_zone(),
663 if start_dt.month < 12:
664 end_dt = start_dt.replace(month=start_dt.month + 1)
666 end_dt = start_dt.replace(year=start_dt.year + 1, month=1)
670 camera: Camera |
None =
None
671 event_camera_id: str |
None =
None
672 if camera_id !=
"all":
673 camera = data.api.bootstrap.cameras.get(camera_id)
674 event_camera_id = camera_id
676 title = f
"{start.strftime('%B %Y')} > {title}"
681 camera_id=event_camera_id,
683 event_types=EVENT_MAP[event_type],
685 source.children = events
690 event_type=event_type,
700 event_type: SimpleEventType,
701 build_children: bool =
False,
702 ) -> BrowseMediaSource:
703 """Build folder media source for a selectors for a given event type."""
705 base_id = f
"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
707 title = EVENT_NAME_MAP[event_type].title()
711 media_class=MediaClass.DIRECTORY,
712 media_content_type=VIDEO_FORMAT,
716 children_media_class=MediaClass.VIDEO,
719 if not build_children
or data.api.bootstrap.recording_start
is None:
723 self.
_build_recent_build_recent(data, camera_id, event_type, 1),
724 self.
_build_recent_build_recent(data, camera_id, event_type, 7),
725 self.
_build_recent_build_recent(data, camera_id, event_type, 30),
730 children.append(self.
_build_month_build_month(data, camera_id, event_type, end.date()))
731 end = (end -
timedelta(days=1)).replace(day=1)
733 camera: Camera |
None =
None
734 if camera_id !=
"all":
735 camera = data.api.bootstrap.cameras.get(camera_id)
736 source.children = await asyncio.gather(*children)
737 source.title = self.
_breadcrumb_breadcrumb(data, title, camera=camera)
742 """Get camera thumbnail URL using the first available camera entity."""
744 if not camera.is_connected
or camera.is_privacy_on:
747 entity_id: str |
None =
None
749 for channel
in camera.channels:
754 base_id = f
"{camera.mac}_{channel.id}"
755 entity_id = entity_registry.async_get_entity_id(
756 Platform.CAMERA, DOMAIN, base_id
758 if entity_id
is None:
759 entity_id = entity_registry.async_get_entity_id(
760 Platform.CAMERA, DOMAIN, f
"{base_id}_insecure"
765 entry = entity_registry.async_get(entity_id)
766 if entry
and not entry.disabled:
770 if entity_id
is not None:
771 url =
URL(CameraImageView.url.format(entity_id=entity_id))
773 url.update_query({
"width": THUMBNAIL_WIDTH,
"height": THUMBNAIL_HEIGHT})
778 self, data: ProtectData, camera_id: str, build_children: bool =
False
779 ) -> BrowseMediaSource:
780 """Build media source for selectors for a UniFi Protect camera."""
783 is_doorbell = data.api.bootstrap.has_doorbell
784 has_smart = data.api.bootstrap.has_smart_detections
785 camera: Camera |
None =
None
786 if camera_id !=
"all":
787 camera = data.api.bootstrap.cameras.get(camera_id)
789 raise BrowseError(f
"Unknown Camera ID: {camera_id}")
790 name = camera.name
or camera.market_name
or camera.type
791 is_doorbell = camera.feature_flags.is_doorbell
792 has_smart = camera.feature_flags.has_smart_detect
794 thumbnail_url: str |
None =
None
795 if camera
is not None:
799 identifier=f
"{data.api.bootstrap.nvr.id}:browse:{camera_id}",
800 media_class=MediaClass.DIRECTORY,
801 media_content_type=VIDEO_FORMAT,
805 thumbnail=thumbnail_url,
806 children_media_class=MediaClass.VIDEO,
809 if not build_children:
813 await self.
_build_events_type_build_events_type(data, camera_id, SimpleEventType.MOTION),
817 source.children.insert(
819 await self.
_build_events_type_build_events_type(data, camera_id, SimpleEventType.RING),
823 source.children.append(
824 await self.
_build_events_type_build_events_type(data, camera_id, SimpleEventType.SMART)
826 source.children.append(
827 await self.
_build_events_type_build_events_type(data, camera_id, SimpleEventType.AUDIO)
830 if is_doorbell
or has_smart:
831 source.children.insert(
833 await self.
_build_events_type_build_events_type(data, camera_id, SimpleEventType.ALL),
836 source.title = self.
_breadcrumb_breadcrumb(data, name)
841 """Build media source for a single UniFi Protect NVR."""
843 cameras: list[BrowseMediaSource] = [await self.
_build_camera_build_camera(data,
"all")]
845 for camera
in data.get_cameras():
846 if not camera.can_read_media(data.api.bootstrap.auth_user):
848 cameras.append(await self.
_build_camera_build_camera(data, camera.id))
853 """Build media source for a single UniFi Protect NVR."""
857 identifier=f
"{data.api.bootstrap.nvr.id}:browse",
858 media_class=MediaClass.DIRECTORY,
859 media_content_type=VIDEO_FORMAT,
860 title=data.api.bootstrap.nvr.name,
863 children_media_class=MediaClass.VIDEO,
868 """Return all media source for all UniFi Protect NVRs."""
870 consoles: list[BrowseMediaSource] = []
871 for data_source
in self.
data_sourcesdata_sources.values():
872 if not data_source.api.bootstrap.has_media:
874 console_source = await self.
_build_console_build_console(data_source)
875 consoles.append(console_source)
877 if len(consoles) == 1:
883 media_class=MediaClass.DIRECTORY,
884 media_content_type=VIDEO_FORMAT,
888 children_media_class=MediaClass.VIDEO,
web.Response get(self, web.Request request, str config_key)
list[UFPConfigEntry] async_get_ufp_entries(HomeAssistant hass)
str async_generate_event_video_url(Event event)
str async_generate_thumbnail_url(str event_id, str nvr_id, int|None width=None, int|None height=None)
datetime_sys datetime(Any value)