Home Assistant Unofficial Reference 2024.12.1
media_source.py
Go to the documentation of this file.
1 """UniFi Protect media sources."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from datetime import date, datetime, timedelta
7 from enum import Enum
8 from typing import Any, NoReturn, cast
9 
10 from uiprotect.data import Camera, Event, EventType, SmartDetectObjectType
11 from uiprotect.exceptions import NvrError
12 from uiprotect.utils import from_js_time
13 from yarl import URL
14 
15 from homeassistant.components.camera import CameraImageView
16 from homeassistant.components.media_player import BrowseError, MediaClass
18  BrowseMediaSource,
19  MediaSource,
20  MediaSourceItem,
21  PlayMedia,
22 )
23 from homeassistant.const import Platform
24 from homeassistant.core import HomeAssistant, callback
25 from homeassistant.helpers import entity_registry as er
26 from homeassistant.util import dt as dt_util
27 
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
31 
32 VIDEO_FORMAT = "video/mp4"
33 THUMBNAIL_WIDTH = 185
34 THUMBNAIL_HEIGHT = 185
35 
36 
37 class SimpleEventType(str, Enum):
38  """Enum to Camera Video events."""
39 
40  ALL = "all"
41  RING = "ring"
42  MOTION = "motion"
43  SMART = "smart"
44  AUDIO = "audio"
45 
46 
47 class IdentifierType(str, Enum):
48  """UniFi Protect identifier type."""
49 
50  EVENT = "event"
51  EVENT_THUMB = "eventthumb"
52  BROWSE = "browse"
53 
54 
55 class IdentifierTimeType(str, Enum):
56  """UniFi Protect identifier subtype."""
57 
58  RECENT = "recent"
59  RANGE = "range"
60 
61 
62 EVENT_MAP: dict[SimpleEventType, set[EventType]] = {
63  SimpleEventType.ALL: {
64  EventType.RING,
65  EventType.MOTION,
66  EventType.SMART_DETECT,
67  EventType.SMART_DETECT_LINE,
68  EventType.SMART_AUDIO_DETECT,
69  },
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},
74 }
75 EVENT_NAME_MAP = {
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",
81 }
82 
83 
84 async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
85  """Set up UniFi Protect media source."""
86  return ProtectMediaSource(
87  hass,
88  {
89  entry.runtime_data.api.bootstrap.nvr.id: entry.runtime_data
90  for entry in async_get_ufp_entries(hass)
91  },
92  )
93 
94 
95 @callback
96 def _get_month_start_end(start: datetime) -> tuple[datetime, datetime]:
97  start = dt_util.as_local(start)
98  end = dt_util.now()
99 
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)
102 
103  return start, end
104 
105 
106 @callback
107 def _bad_identifier(identifier: str, err: Exception | None = None) -> NoReturn:
108  msg = f"Unexpected identifier: {identifier}"
109  if err is None:
110  raise BrowseError(msg)
111  raise BrowseError(msg) from err
112 
113 
114 @callback
115 def _format_duration(duration: timedelta) -> str:
116  formatted = ""
117  seconds = int(duration.total_seconds())
118  if seconds > 3600:
119  hours = seconds // 3600
120  formatted += f"{hours}h "
121  seconds -= hours * 3600
122  if seconds > 60:
123  minutes = seconds // 60
124  formatted += f"{minutes}m "
125  seconds -= minutes * 60
126  if seconds > 0:
127  formatted += f"{seconds}s "
128 
129  return formatted.strip()
130 
131 
132 @callback
133 def _get_object_name(event: Event | dict[str, Any]) -> str:
134  if isinstance(event, Event):
135  event = event.unifi_dict()
136 
137  names = []
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:
143  continue
144 
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")
151 
152  name = f"{color} {vehicle_type}".strip().title()
153  if license_plate:
154  types.remove(SmartDetectObjectType.LICENSE_PLATE.value)
155  name = f"{name}: {license_plate}"
156  names.append(name)
157  else:
158  smart_type = SmartDetectObjectType(thumb_type)
159  names.append(smart_type.name.title().replace("_", " "))
160 
161  for raw in types:
162  smart_type = SmartDetectObjectType(raw)
163  names.append(smart_type.name.title().replace("_", " "))
164 
165  return ", ".join(sorted(names))
166 
167 
168 @callback
169 def _get_audio_name(event: Event | dict[str, Any]) -> str:
170  if isinstance(event, Event):
171  event = event.unifi_dict()
172 
173  smart_types = [SmartDetectObjectType(e) for e in event["smartDetectTypes"]]
174  return ", ".join([s.name.title().replace("_", " ") for s in smart_types])
175 
176 
178  """Represents all UniFi Protect NVRs."""
179 
180  name: str = "UniFi Protect"
181  _registry: er.EntityRegistry | None
182 
183  def __init__(
184  self, hass: HomeAssistant, data_sources: dict[str, ProtectData]
185  ) -> None:
186  """Initialize the UniFi Protect media source."""
187 
188  super().__init__(DOMAIN)
189  self.hasshass = hass
190  self.data_sourcesdata_sources = data_sources
191  self._registry_registry = None
192 
193  async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
194  """Return a streamable URL and associated mime type for a UniFi Protect event.
195 
196  Accepted identifier format are
197 
198  * {nvr_id}:event:{event_id} - MP4 video clip for specific event
199  * {nvr_id}:eventthumb:{event_id} - Thumbnail JPEG for specific event
200  """
201 
202  parts = item.identifier.split(":")
203  if len(parts) != 3 or parts[1] not in ("event", "eventthumb"):
204  _bad_identifier(item.identifier)
205 
206  thumbnail_only = parts[1] == "eventthumb"
207  try:
208  data = self.data_sourcesdata_sources[parts[0]]
209  except (KeyError, IndexError) as err:
210  _bad_identifier(item.identifier, err)
211 
212  event = data.api.bootstrap.events.get(parts[2])
213  if event is None:
214  try:
215  event = await data.api.get_event(parts[2])
216  except NvrError as err:
217  _bad_identifier(item.identifier, err)
218  else:
219  # cache the event for later
220  data.api.bootstrap.events[event.id] = event
221 
222  nvr = data.api.bootstrap.nvr
223  if thumbnail_only:
224  return PlayMedia(
225  async_generate_thumbnail_url(event.id, nvr.id), "image/jpeg"
226  )
227  return PlayMedia(async_generate_event_video_url(event), "video/mp4")
228 
229  async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
230  """Return a browsable UniFi Protect media source.
231 
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.
235 
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.
240 
241  * ... Each NVR Console (hidden if there is only one)
242  * All Cameras
243  * ... Camera X
244  * All Events
245  * ... Event Type X
246  * Last 24 Hours -> Events
247  * Last 7 Days -> Events
248  * Last 30 Days -> Events
249  * ... This Month - X
250  * Whole Month -> Events
251  * ... Day X -> Events
252 
253  Accepted identifier formats:
254 
255  * {nvr_id}:event:{event_id}
256  Specific Event for NVR
257  * {nvr_id}:eventthumb:{event_id}
258  Specific Event Thumbnail for NVR
259  * {nvr_id}:browse
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
271  """
272 
273  if not item.identifier:
274  return await self._build_sources_build_sources()
275 
276  parts = item.identifier.split(":")
277 
278  try:
279  data = self.data_sourcesdata_sources[parts[0]]
280  except (KeyError, IndexError) as err:
281  _bad_identifier(item.identifier, err)
282 
283  if len(parts) < 2:
284  _bad_identifier(item.identifier)
285 
286  try:
287  identifier_type = IdentifierType(parts[1])
288  except ValueError as err:
289  _bad_identifier(item.identifier, err)
290 
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)
294 
295  # rest are params for browse
296  parts = parts[2:]
297 
298  # {nvr_id}:browse
299  if len(parts) == 0:
300  return await self._build_console_build_console(data)
301 
302  # {nvr_id}:browse:all|{camera_id}
303  camera_id = parts.pop(0)
304  if len(parts) == 0:
305  return await self._build_camera_build_camera(data, camera_id, build_children=True)
306 
307  # {nvr_id}:browse:all|{camera_id}:all|{event_type}
308  try:
309  event_type = SimpleEventType(parts.pop(0).lower())
310  except (IndexError, ValueError) as err:
311  _bad_identifier(item.identifier, err)
312 
313  if len(parts) == 0:
314  return await self._build_events_type_build_events_type(
315  data, camera_id, event_type, build_children=True
316  )
317 
318  try:
319  time_type = IdentifierTimeType(parts.pop(0))
320  except ValueError as err:
321  _bad_identifier(item.identifier, err)
322 
323  if len(parts) == 0:
324  _bad_identifier(item.identifier)
325 
326  # {nvr_id}:browse:all|{camera_id}:all|{event_type}:recent:{day_count}
327  if time_type == IdentifierTimeType.RECENT:
328  try:
329  days = int(parts.pop(0))
330  except (IndexError, ValueError) as err:
331  _bad_identifier(item.identifier, err)
332 
333  return await self._build_recent_build_recent(
334  data, camera_id, event_type, days, build_children=True
335  )
336 
337  # {nvr_id}:all|{camera_id}:all|{event_type}:range:{year}:{month}
338  # {nvr_id}:all|{camera_id}:all|{event_type}:range:{year}:{month}:all|{day}
339  try:
340  start, is_month, is_all = self._parse_range_parse_range(parts)
341  except (IndexError, ValueError) as err:
342  _bad_identifier(item.identifier, err)
343 
344  if is_month:
345  return await self._build_month_build_month(
346  data, camera_id, event_type, start, build_children=True
347  )
348  return await self._build_days_build_days(
349  data, camera_id, event_type, start, build_children=True, is_all=is_all
350  )
351 
352  def _parse_range(self, parts: list[str]) -> tuple[date, bool, bool]:
353  day = 1
354  is_month = True
355  is_all = True
356  year = int(parts[0])
357  month = int(parts[1])
358  if len(parts) == 3:
359  is_month = False
360  if parts[2] != "all":
361  is_all = False
362  day = int(parts[2])
363 
364  start = date(year=year, month=month, day=day)
365  return start, is_month, is_all
366 
367  async def _resolve_event(
368  self, data: ProtectData, event_id: str, thumbnail_only: bool = False
369  ) -> BrowseMediaSource:
370  """Resolve a specific event."""
371 
372  subtype = "eventthumb" if thumbnail_only else "event"
373  try:
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)
377 
378  if event.start is None or event.end is None:
379  raise BrowseError("Event is still ongoing")
380 
381  return await self._build_event_build_event(data, event, thumbnail_only)
382 
383  @callback
384  def async_get_registry(self) -> er.EntityRegistry:
385  """Get or return Entity Registry."""
386  if self._registry_registry is None:
387  self._registry_registry = er.async_get(self.hasshass)
388  return self._registry_registry
389 
391  self,
392  data: ProtectData,
393  base_title: str,
394  camera: Camera | None = None,
395  event_type: SimpleEventType | None = None,
396  count: int | None = None,
397  ) -> str:
398  title = base_title
399  if count is not None:
400  if count == data.max_events:
401  title = f"{title} ({count} TRUNCATED)"
402  else:
403  title = f"{title} ({count})"
404 
405  if event_type is not None:
406  title = f"{EVENT_NAME_MAP[event_type].title()} > {title}"
407 
408  if camera is not None:
409  title = f"{camera.display_name} > {title}"
410  return f"{data.api.bootstrap.nvr.display_name} > {title}"
411 
412  async def _build_event(
413  self,
414  data: ProtectData,
415  event: dict[str, Any] | Event,
416  thumbnail_only: bool = False,
417  ) -> BrowseMediaSource:
418  """Build media source for an individual event."""
419 
420  if isinstance(event, Event):
421  event_id = event.id
422  event_type = event.type
423  start = event.start
424  end = event.end
425  else:
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"])
430 
431  assert end is not None
432 
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}"
445 
446  nvr = data.api.bootstrap.nvr
447  if thumbnail_only:
448  return BrowseMediaSource(
449  domain=DOMAIN,
450  identifier=f"{nvr.id}:eventthumb:{event_id}",
451  media_class=MediaClass.IMAGE,
452  media_content_type="image/jpeg",
453  title=title,
454  can_play=True,
455  can_expand=False,
457  event_id, nvr.id, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT
458  ),
459  )
460 
461  return BrowseMediaSource(
462  domain=DOMAIN,
463  identifier=f"{nvr.id}:event:{event_id}",
464  media_class=MediaClass.VIDEO,
465  media_content_type="video/mp4",
466  title=title,
467  can_play=True,
468  can_expand=False,
470  event_id, nvr.id, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT
471  ),
472  )
473 
474  async def _build_events(
475  self,
476  data: ProtectData,
477  start: datetime,
478  end: datetime,
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."""
484 
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
490  )
491  events = sorted(events, key=lambda e: cast(int, e["start"]), reverse=reserve)
492  for event in events:
493  # do not process ongoing events
494  if event.get("start") is None or event.get("end") is None:
495  continue
496 
497  if camera_id is not None and event.get("camera") != camera_id:
498  continue
499 
500  # smart detect events have a paired motion event
501  if event.get("type") == EventType.MOTION.value and event.get(
502  "smartDetectEvents"
503  ):
504  continue
505 
506  sources.append(await self._build_event_build_event(data, event))
507 
508  return sources
509 
510  async def _build_recent(
511  self,
512  data: ProtectData,
513  camera_id: str,
514  event_type: SimpleEventType,
515  days: int,
516  build_children: bool = False,
517  ) -> BrowseMediaSource:
518  """Build media source for events in relative days."""
519 
520  base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
521  title = f"Last {days} Days"
522  if days == 1:
523  title = "Last 24 Hours"
524 
525  source = BrowseMediaSource(
526  domain=DOMAIN,
527  identifier=f"{base_id}:recent:{days}",
528  media_class=MediaClass.DIRECTORY,
529  media_content_type="video/mp4",
530  title=title,
531  can_play=False,
532  can_expand=True,
533  children_media_class=MediaClass.VIDEO,
534  )
535 
536  if not build_children:
537  return source
538 
539  now = dt_util.now()
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
545 
546  events = await self._build_events_build_events(
547  data=data,
548  start=now - timedelta(days=days),
549  end=now,
550  camera_id=event_camera_id,
551  event_types=EVENT_MAP[event_type],
552  reserve=True,
553  )
554  source.children = events
555  source.title = self._breadcrumb_breadcrumb(
556  data,
557  title,
558  camera=camera,
559  event_type=event_type,
560  count=len(events),
561  )
562  return source
563 
564  async def _build_month(
565  self,
566  data: ProtectData,
567  camera_id: str,
568  event_type: SimpleEventType,
569  start: date,
570  build_children: bool = False,
571  ) -> BrowseMediaSource:
572  """Build media source for selectors for a given month."""
573 
574  base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
575 
576  title = f"{start.strftime('%B %Y')}"
577  source = BrowseMediaSource(
578  domain=DOMAIN,
579  identifier=f"{base_id}:range:{start.year}:{start.month}",
580  media_class=MediaClass.DIRECTORY,
581  media_content_type=VIDEO_FORMAT,
582  title=title,
583  can_play=False,
584  can_expand=True,
585  children_media_class=MediaClass.VIDEO,
586  )
587 
588  if not build_children:
589  return source
590 
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)
594 
595  recording_end = dt_util.now().date()
596  end = start.replace(month=start.month + 1) - timedelta(days=1)
597  end = min(recording_end, end)
598 
599  children = [self._build_days_build_days(data, camera_id, event_type, start, is_all=True)]
600  while start <= end:
601  children.append(
602  self._build_days_build_days(data, camera_id, event_type, start, is_all=False)
603  )
604  start = start + timedelta(hours=24)
605 
606  camera: Camera | None = None
607  if camera_id != "all":
608  camera = data.api.bootstrap.cameras.get(camera_id)
609 
610  source.children = await asyncio.gather(*children)
611  source.title = self._breadcrumb_breadcrumb(
612  data,
613  title,
614  camera=camera,
615  event_type=event_type,
616  )
617 
618  return source
619 
620  async def _build_days(
621  self,
622  data: ProtectData,
623  camera_id: str,
624  event_type: SimpleEventType,
625  start: date,
626  is_all: bool = True,
627  build_children: bool = False,
628  ) -> BrowseMediaSource:
629  """Build media source for events for a given day or whole month."""
630 
631  base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
632 
633  if is_all:
634  title = "Whole Month"
635  identifier = f"{base_id}:range:{start.year}:{start.month}:all"
636  else:
637  title = f"{start.strftime('%x')}"
638  identifier = f"{base_id}:range:{start.year}:{start.month}:{start.day}"
639  source = BrowseMediaSource(
640  domain=DOMAIN,
641  identifier=identifier,
642  media_class=MediaClass.DIRECTORY,
643  media_content_type=VIDEO_FORMAT,
644  title=title,
645  can_play=False,
646  can_expand=True,
647  children_media_class=MediaClass.VIDEO,
648  )
649 
650  if not build_children:
651  return source
652 
653  start_dt = datetime(
654  year=start.year,
655  month=start.month,
656  day=start.day,
657  hour=0,
658  minute=0,
659  second=0,
660  tzinfo=dt_util.get_default_time_zone(),
661  )
662  if is_all:
663  if start_dt.month < 12:
664  end_dt = start_dt.replace(month=start_dt.month + 1)
665  else:
666  end_dt = start_dt.replace(year=start_dt.year + 1, month=1)
667  else:
668  end_dt = start_dt + timedelta(hours=24)
669 
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
675 
676  title = f"{start.strftime('%B %Y')} > {title}"
677  events = await self._build_events_build_events(
678  data=data,
679  start=start_dt,
680  end=end_dt,
681  camera_id=event_camera_id,
682  reserve=False,
683  event_types=EVENT_MAP[event_type],
684  )
685  source.children = events
686  source.title = self._breadcrumb_breadcrumb(
687  data,
688  title,
689  camera=camera,
690  event_type=event_type,
691  count=len(events),
692  )
693 
694  return source
695 
697  self,
698  data: ProtectData,
699  camera_id: str,
700  event_type: SimpleEventType,
701  build_children: bool = False,
702  ) -> BrowseMediaSource:
703  """Build folder media source for a selectors for a given event type."""
704 
705  base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
706 
707  title = EVENT_NAME_MAP[event_type].title()
708  source = BrowseMediaSource(
709  domain=DOMAIN,
710  identifier=base_id,
711  media_class=MediaClass.DIRECTORY,
712  media_content_type=VIDEO_FORMAT,
713  title=title,
714  can_play=False,
715  can_expand=True,
716  children_media_class=MediaClass.VIDEO,
717  )
718 
719  if not build_children or data.api.bootstrap.recording_start is None:
720  return source
721 
722  children = [
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),
726  ]
727 
728  start, end = _get_month_start_end(data.api.bootstrap.recording_start)
729  while end > start:
730  children.append(self._build_month_build_month(data, camera_id, event_type, end.date()))
731  end = (end - timedelta(days=1)).replace(day=1)
732 
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)
738 
739  return source
740 
741  async def _get_camera_thumbnail_url(self, camera: Camera) -> str | None:
742  """Get camera thumbnail URL using the first available camera entity."""
743 
744  if not camera.is_connected or camera.is_privacy_on:
745  return None
746 
747  entity_id: str | None = None
748  entity_registry = self.async_get_registryasync_get_registry()
749  for channel in camera.channels:
750  # do not use the package camera
751  if channel.id == 3:
752  continue
753 
754  base_id = f"{camera.mac}_{channel.id}"
755  entity_id = entity_registry.async_get_entity_id(
756  Platform.CAMERA, DOMAIN, base_id
757  )
758  if entity_id is None:
759  entity_id = entity_registry.async_get_entity_id(
760  Platform.CAMERA, DOMAIN, f"{base_id}_insecure"
761  )
762 
763  if entity_id:
764  # verify entity is available
765  entry = entity_registry.async_get(entity_id)
766  if entry and not entry.disabled:
767  break
768  entity_id = None
769 
770  if entity_id is not None:
771  url = URL(CameraImageView.url.format(entity_id=entity_id))
772  return str(
773  url.update_query({"width": THUMBNAIL_WIDTH, "height": THUMBNAIL_HEIGHT})
774  )
775  return None
776 
777  async def _build_camera(
778  self, data: ProtectData, camera_id: str, build_children: bool = False
779  ) -> BrowseMediaSource:
780  """Build media source for selectors for a UniFi Protect camera."""
781 
782  name = "All Cameras"
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)
788  if camera is None:
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
793 
794  thumbnail_url: str | None = None
795  if camera is not None:
796  thumbnail_url = await self._get_camera_thumbnail_url_get_camera_thumbnail_url(camera)
797  source = BrowseMediaSource(
798  domain=DOMAIN,
799  identifier=f"{data.api.bootstrap.nvr.id}:browse:{camera_id}",
800  media_class=MediaClass.DIRECTORY,
801  media_content_type=VIDEO_FORMAT,
802  title=name,
803  can_play=False,
804  can_expand=True,
805  thumbnail=thumbnail_url,
806  children_media_class=MediaClass.VIDEO,
807  )
808 
809  if not build_children:
810  return source
811 
812  source.children = [
813  await self._build_events_type_build_events_type(data, camera_id, SimpleEventType.MOTION),
814  ]
815 
816  if is_doorbell:
817  source.children.insert(
818  0,
819  await self._build_events_type_build_events_type(data, camera_id, SimpleEventType.RING),
820  )
821 
822  if has_smart:
823  source.children.append(
824  await self._build_events_type_build_events_type(data, camera_id, SimpleEventType.SMART)
825  )
826  source.children.append(
827  await self._build_events_type_build_events_type(data, camera_id, SimpleEventType.AUDIO)
828  )
829 
830  if is_doorbell or has_smart:
831  source.children.insert(
832  0,
833  await self._build_events_type_build_events_type(data, camera_id, SimpleEventType.ALL),
834  )
835 
836  source.title = self._breadcrumb_breadcrumb(data, name)
837 
838  return source
839 
840  async def _build_cameras(self, data: ProtectData) -> list[BrowseMediaSource]:
841  """Build media source for a single UniFi Protect NVR."""
842 
843  cameras: list[BrowseMediaSource] = [await self._build_camera_build_camera(data, "all")]
844 
845  for camera in data.get_cameras():
846  if not camera.can_read_media(data.api.bootstrap.auth_user):
847  continue
848  cameras.append(await self._build_camera_build_camera(data, camera.id))
849 
850  return cameras
851 
852  async def _build_console(self, data: ProtectData) -> BrowseMediaSource:
853  """Build media source for a single UniFi Protect NVR."""
854 
855  return BrowseMediaSource(
856  domain=DOMAIN,
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,
861  can_play=False,
862  can_expand=True,
863  children_media_class=MediaClass.VIDEO,
864  children=await self._build_cameras_build_cameras(data),
865  )
866 
867  async def _build_sources(self) -> BrowseMediaSource:
868  """Return all media source for all UniFi Protect NVRs."""
869 
870  consoles: list[BrowseMediaSource] = []
871  for data_source in self.data_sourcesdata_sources.values():
872  if not data_source.api.bootstrap.has_media:
873  continue
874  console_source = await self._build_console_build_console(data_source)
875  consoles.append(console_source)
876 
877  if len(consoles) == 1:
878  return consoles[0]
879 
880  return BrowseMediaSource(
881  domain=DOMAIN,
882  identifier=None,
883  media_class=MediaClass.DIRECTORY,
884  media_content_type=VIDEO_FORMAT,
885  title=self.namename,
886  can_play=False,
887  can_expand=True,
888  children_media_class=MediaClass.VIDEO,
889  children=consoles,
890  )
BrowseMediaSource async_browse_media(self, MediaSourceItem item)
tuple[date, bool, bool] _parse_range(self, list[str] parts)
BrowseMediaSource _build_days(self, ProtectData data, str camera_id, SimpleEventType event_type, date start, bool is_all=True, bool build_children=False)
BrowseMediaSource _build_events_type(self, ProtectData data, str camera_id, SimpleEventType event_type, bool build_children=False)
BrowseMediaSource _build_recent(self, ProtectData data, str camera_id, SimpleEventType event_type, int days, bool build_children=False)
None __init__(self, HomeAssistant hass, dict[str, ProtectData] data_sources)
BrowseMediaSource _build_camera(self, ProtectData data, str camera_id, bool build_children=False)
BrowseMediaSource _build_month(self, ProtectData data, str camera_id, SimpleEventType event_type, date start, bool build_children=False)
BrowseMediaSource _build_event(self, ProtectData data, dict[str, Any]|Event event, bool thumbnail_only=False)
BrowseMediaSource _resolve_event(self, ProtectData data, str event_id, bool thumbnail_only=False)
list[BrowseMediaSource] _build_cameras(self, ProtectData data)
str _breadcrumb(self, ProtectData data, str base_title, Camera|None camera=None, SimpleEventType|None event_type=None, int|None count=None)
list[BrowseMediaSource] _build_events(self, ProtectData data, datetime start, datetime end, str|None camera_id=None, set[EventType]|None event_types=None, bool reserve=False)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
list[UFPConfigEntry] async_get_ufp_entries(HomeAssistant hass)
Definition: data.py:360
MediaSource async_get_media_source(HomeAssistant hass)
Definition: media_source.py:84
str _get_audio_name(Event|dict[str, Any] event)
NoReturn _bad_identifier(str identifier, Exception|None err=None)
str _get_object_name(Event|dict[str, Any] event)
tuple[datetime, datetime] _get_month_start_end(datetime start)
Definition: media_source.py:96
str async_generate_event_video_url(Event event)
Definition: views.py:48
str async_generate_thumbnail_url(str event_id, str nvr_id, int|None width=None, int|None height=None)
Definition: views.py:30