"""UniFi Protect media sources.""" from __future__ import annotations import asyncio from datetime import date, datetime, timedelta from enum import Enum from typing import Any, NoReturn, cast from pyunifiprotect.data import ( Camera, Event, EventType, ModelType, SmartDetectObjectType, ) from pyunifiprotect.exceptions import NvrError from pyunifiprotect.utils import from_js_time from yarl import URL from homeassistant.components.camera import CameraImageView from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.components.media_source.models import ( BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia, ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .const import DOMAIN from .data import ProtectData from .views import async_generate_event_video_url, async_generate_thumbnail_url VIDEO_FORMAT = "video/mp4" THUMBNAIL_WIDTH = 185 THUMBNAIL_HEIGHT = 185 class SimpleEventType(str, Enum): """Enum to Camera Video events.""" ALL = "all" RING = "ring" MOTION = "motion" SMART = "smart" class IdentifierType(str, Enum): """UniFi Protect identifier type.""" EVENT = "event" EVENT_THUMB = "eventthumb" BROWSE = "browse" class IdentifierTimeType(str, Enum): """UniFi Protect identifier subtype.""" RECENT = "recent" RANGE = "range" EVENT_MAP = { SimpleEventType.ALL: None, SimpleEventType.RING: EventType.RING, SimpleEventType.MOTION: EventType.MOTION, SimpleEventType.SMART: EventType.SMART_DETECT, } EVENT_NAME_MAP = { SimpleEventType.ALL: "All Events", SimpleEventType.RING: "Ring Events", SimpleEventType.MOTION: "Motion Events", SimpleEventType.SMART: "Smart Detections", } def get_ufp_event(event_type: SimpleEventType) -> EventType | None: """Get UniFi Protect event type from SimpleEventType.""" return EVENT_MAP[event_type] async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up UniFi Protect media source.""" data_sources: dict[str, ProtectData] = {} for data in hass.data.get(DOMAIN, {}).values(): if isinstance(data, ProtectData): data_sources[data.api.bootstrap.nvr.id] = data return ProtectMediaSource(hass, data_sources) @callback def _get_month_start_end(start: datetime) -> tuple[datetime, datetime]: start = dt_util.as_local(start) end = dt_util.now() start = start.replace(day=1, hour=0, minute=0, second=1, microsecond=0) end = end.replace(day=1, hour=0, minute=0, second=2, microsecond=0) return start, end @callback def _bad_identifier(identifier: str, err: Exception | None = None) -> NoReturn: msg = f"Unexpected identifier: {identifier}" if err is None: raise BrowseError(msg) raise BrowseError(msg) from err @callback def _format_duration(duration: timedelta) -> str: formatted = "" seconds = int(duration.total_seconds()) if seconds > 3600: hours = seconds // 3600 formatted += f"{hours}h " seconds -= hours * 3600 if seconds > 60: minutes = seconds // 60 formatted += f"{minutes}m " seconds -= minutes * 60 if seconds > 0: formatted += f"{seconds}s " return formatted.strip() class ProtectMediaSource(MediaSource): """Represents all UniFi Protect NVRs.""" name: str = "UniFi Protect" _registry: er.EntityRegistry | None def __init__( self, hass: HomeAssistant, data_sources: dict[str, ProtectData] ) -> None: """Initialize the UniFi Protect media source.""" super().__init__(DOMAIN) self.hass = hass self.data_sources = data_sources self._registry = None async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Return a streamable URL and associated mime type for a UniFi Protect event. Accepted identifier format are * {nvr_id}:event:{event_id} - MP4 video clip for specific event * {nvr_id}:eventthumb:{event_id} - Thumbnail JPEG for specific event """ parts = item.identifier.split(":") if len(parts) != 3 or parts[1] not in ("event", "eventthumb"): _bad_identifier(item.identifier) thumbnail_only = parts[1] == "eventthumb" try: data = self.data_sources[parts[0]] except (KeyError, IndexError) as err: _bad_identifier(item.identifier, err) event = data.api.bootstrap.events.get(parts[2]) if event is None: try: event = await data.api.get_event(parts[2]) except NvrError as err: _bad_identifier(item.identifier, err) else: # cache the event for later data.api.bootstrap.events[event.id] = event nvr = data.api.bootstrap.nvr if thumbnail_only: return PlayMedia( async_generate_thumbnail_url(event.id, nvr.id), "image/jpeg" ) return PlayMedia(async_generate_event_video_url(event), "video/mp4") async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: """Return a browsable UniFi Protect media source. Identifier formatters for UniFi Protect media sources are all in IDs from the UniFi Protect instance since events may not always map 1:1 to a Home Assistant device or entity. It also drasically speeds up resolution. The UniFi Protect Media source is timebased for the events recorded by the NVR. So its structure is a bit different then many other media players. All browsable media is a video clip. The media source could be greatly cleaned up if/when the frontend has filtering supporting. * ... Each NVR Console (hidden if there is only one) * All Cameras * ... Camera X * All Events * ... Event Type X * Last 24 Hours -> Events * Last 7 Days -> Events * Last 30 Days -> Events * ... This Month - X * Whole Month -> Events * ... Day X -> Events Accepted identifier formats: * {nvr_id}:event:{event_id} Specific Event for NVR * {nvr_id}:eventthumb:{event_id} Specific Event Thumbnail for NVR * {nvr_id}:browse Root NVR browse source * {nvr_id}:browse:all|{camera_id} Root Camera(s) browse source * {nvr_id}:browse:all|{camera_id}:all|{event_type} Root Camera(s) Event Type(s) browse source * {nvr_id}:browse:all|{camera_id}:all|{event_type}:recent:{day_count} Listing of all events in last {day_count}, sorted in reverse chronological order * {nvr_id}:browse:all|{camera_id}:all|{event_type}:range:{year}:{month} List of folders for each day in month + all events for month * {nvr_id}:browse:all|{camera_id}:all|{event_type}:range:{year}:{month}:all|{day} Listing of all events for give {day} + {month} + {year} combination in chronological order """ if not item.identifier: return await self._build_sources() parts = item.identifier.split(":") try: data = self.data_sources[parts[0]] except (KeyError, IndexError) as err: _bad_identifier(item.identifier, err) if len(parts) < 2: _bad_identifier(item.identifier) try: identifier_type = IdentifierType(parts[1]) except ValueError as err: _bad_identifier(item.identifier, err) if identifier_type in (IdentifierType.EVENT, IdentifierType.EVENT_THUMB): thumbnail_only = identifier_type == IdentifierType.EVENT_THUMB return await self._resolve_event(data, parts[2], thumbnail_only) # rest are params for browse parts = parts[2:] # {nvr_id}:browse if len(parts) == 0: return await self._build_console(data) # {nvr_id}:browse:all|{camera_id} camera_id = parts.pop(0) if len(parts) == 0: return await self._build_camera(data, camera_id, build_children=True) # {nvr_id}:browse:all|{camera_id}:all|{event_type} try: event_type = SimpleEventType(parts.pop(0).lower()) except (IndexError, ValueError) as err: _bad_identifier(item.identifier, err) if len(parts) == 0: return await self._build_events_type( data, camera_id, event_type, build_children=True ) try: time_type = IdentifierTimeType(parts.pop(0)) except ValueError as err: _bad_identifier(item.identifier, err) if len(parts) == 0: _bad_identifier(item.identifier) # {nvr_id}:browse:all|{camera_id}:all|{event_type}:recent:{day_count} if time_type == IdentifierTimeType.RECENT: try: days = int(parts.pop(0)) except (IndexError, ValueError) as err: _bad_identifier(item.identifier, err) return await self._build_recent( data, camera_id, event_type, days, build_children=True ) # {nvr_id}:all|{camera_id}:all|{event_type}:range:{year}:{month} # {nvr_id}:all|{camera_id}:all|{event_type}:range:{year}:{month}:all|{day} try: start, is_month, is_all = self._parse_range(parts) except (IndexError, ValueError) as err: _bad_identifier(item.identifier, err) if is_month: return await self._build_month( data, camera_id, event_type, start, build_children=True ) return await self._build_days( data, camera_id, event_type, start, build_children=True, is_all=is_all ) def _parse_range(self, parts: list[str]) -> tuple[date, bool, bool]: day = 1 is_month = True is_all = True year = int(parts[0]) month = int(parts[1]) if len(parts) == 3: is_month = False if parts[2] != "all": is_all = False day = int(parts[2]) start = date(year=year, month=month, day=day) return start, is_month, is_all async def _resolve_event( self, data: ProtectData, event_id: str, thumbnail_only: bool = False ) -> BrowseMediaSource: """Resolve a specific event.""" subtype = "eventthumb" if thumbnail_only else "event" try: event = await data.api.get_event(event_id) except NvrError as err: _bad_identifier(f"{data.api.bootstrap.nvr.id}:{subtype}:{event_id}", err) if event.start is None or event.end is None: raise BrowseError("Event is still ongoing") return await self._build_event(data, event, thumbnail_only) @callback def async_get_registry(self) -> er.EntityRegistry: """Get or return Entity Registry.""" if self._registry is None: self._registry = er.async_get(self.hass) return self._registry def _breadcrumb( self, data: ProtectData, base_title: str, camera: Camera | None = None, event_type: SimpleEventType | None = None, count: int | None = None, ) -> str: title = base_title if count is not None: if count == data.max_events: title = f"{title} ({count} TRUNCATED)" else: title = f"{title} ({count})" if event_type is not None: title = f"{EVENT_NAME_MAP[event_type].title()} > {title}" if camera is not None: title = f"{camera.display_name} > {title}" title = f"{data.api.bootstrap.nvr.display_name} > {title}" return title async def _build_event( self, data: ProtectData, event: dict[str, Any] | Event, thumbnail_only: bool = False, ) -> BrowseMediaSource: """Build media source for an individual event.""" if isinstance(event, Event): event_id = event.id event_type = event.type start = event.start end = event.end else: event_id = event["id"] event_type = event["type"] start = from_js_time(event["start"]) end = from_js_time(event["end"]) assert end is not None title = dt_util.as_local(start).strftime("%x %X") duration = end - start title += f" {_format_duration(duration)}" if event_type == EventType.RING.value: event_text = "Ring Event" elif event_type == EventType.MOTION.value: event_text = "Motion Event" elif event_type == EventType.SMART_DETECT.value: if isinstance(event, Event): smart_types = event.smart_detect_types else: smart_types = [ SmartDetectObjectType(e) for e in event["smartDetectTypes"] ] smart_type_names = [s.name.title().replace("_", " ") for s in smart_types] event_text = f"Smart Detection - {','.join(smart_type_names)}" title += f" {event_text}" nvr = data.api.bootstrap.nvr if thumbnail_only: return BrowseMediaSource( domain=DOMAIN, identifier=f"{nvr.id}:eventthumb:{event_id}", media_class=MediaClass.IMAGE, media_content_type="image/jpeg", title=title, can_play=True, can_expand=False, thumbnail=async_generate_thumbnail_url( event_id, nvr.id, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT ), ) return BrowseMediaSource( domain=DOMAIN, identifier=f"{nvr.id}:event:{event_id}", media_class=MediaClass.VIDEO, media_content_type="video/mp4", title=title, can_play=True, can_expand=False, thumbnail=async_generate_thumbnail_url( event_id, nvr.id, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT ), ) async def _build_events( self, data: ProtectData, start: datetime, end: datetime, camera_id: str | None = None, event_type: EventType | None = None, reserve: bool = False, ) -> list[BrowseMediaSource]: """Build media source for a given range of time and event type.""" if event_type is None: types = [ EventType.RING, EventType.MOTION, EventType.SMART_DETECT, ] else: types = [event_type] sources: list[BrowseMediaSource] = [] events = await data.api.get_events_raw( start=start, end=end, types=types, limit=data.max_events ) events = sorted(events, key=lambda e: cast(int, e["start"]), reverse=reserve) for event in events: # do not process ongoing events if event.get("start") is None or event.get("end") is None: continue if camera_id is not None and event.get("camera") != camera_id: continue # smart detect events have a paired motion event if event.get("type") == EventType.MOTION.value and event.get( "smartDetectEvents" ): continue sources.append(await self._build_event(data, event)) return sources async def _build_recent( self, data: ProtectData, camera_id: str, event_type: SimpleEventType, days: int, build_children: bool = False, ) -> BrowseMediaSource: """Build media source for events in relative days.""" base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}" title = f"Last {days} Days" if days == 1: title = "Last 24 Hours" source = BrowseMediaSource( domain=DOMAIN, identifier=f"{base_id}:recent:{days}", media_class=MediaClass.DIRECTORY, media_content_type="video/mp4", title=title, can_play=False, can_expand=True, children_media_class=MediaClass.VIDEO, ) if not build_children: return source now = dt_util.now() args = { "data": data, "start": now - timedelta(days=days), "end": now, "reserve": True, } if event_type != SimpleEventType.ALL: args["event_type"] = get_ufp_event(event_type) camera: Camera | None = None if camera_id != "all": camera = data.api.bootstrap.cameras.get(camera_id) args["camera_id"] = camera_id events = await self._build_events(**args) # type: ignore[arg-type] source.children = events source.title = self._breadcrumb( data, title, camera=camera, event_type=event_type, count=len(events), ) return source async def _build_month( self, data: ProtectData, camera_id: str, event_type: SimpleEventType, start: date, build_children: bool = False, ) -> BrowseMediaSource: """Build media source for selectors for a given month.""" base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}" title = f"{start.strftime('%B %Y')}" source = BrowseMediaSource( domain=DOMAIN, identifier=f"{base_id}:range:{start.year}:{start.month}", media_class=MediaClass.DIRECTORY, media_content_type=VIDEO_FORMAT, title=title, can_play=False, can_expand=True, children_media_class=MediaClass.VIDEO, ) if not build_children: return source if data.api.bootstrap.recording_start is not None: recording_start = data.api.bootstrap.recording_start.date() start = max(recording_start, start) recording_end = dt_util.now().date() end = start.replace(month=start.month + 1) - timedelta(days=1) end = min(recording_end, end) children = [self._build_days(data, camera_id, event_type, start, is_all=True)] while start <= end: children.append( self._build_days(data, camera_id, event_type, start, is_all=False) ) start = start + timedelta(hours=24) camera: Camera | None = None if camera_id != "all": camera = data.api.bootstrap.cameras.get(camera_id) source.children = await asyncio.gather(*children) source.title = self._breadcrumb( data, title, camera=camera, event_type=event_type, ) return source async def _build_days( self, data: ProtectData, camera_id: str, event_type: SimpleEventType, start: date, is_all: bool = True, build_children: bool = False, ) -> BrowseMediaSource: """Build media source for events for a given day or whole month.""" base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}" if is_all: title = "Whole Month" identifier = f"{base_id}:range:{start.year}:{start.month}:all" else: title = f"{start.strftime('%x')}" identifier = f"{base_id}:range:{start.year}:{start.month}:{start.day}" source = BrowseMediaSource( domain=DOMAIN, identifier=identifier, media_class=MediaClass.DIRECTORY, media_content_type=VIDEO_FORMAT, title=title, can_play=False, can_expand=True, children_media_class=MediaClass.VIDEO, ) if not build_children: return source start_dt = datetime( year=start.year, month=start.month, day=start.day, hour=0, minute=0, second=0, tzinfo=dt_util.DEFAULT_TIME_ZONE, ) if is_all: if start_dt.month < 12: end_dt = start_dt.replace(month=start_dt.month + 1) else: end_dt = start_dt.replace(year=start_dt.year + 1, month=1) else: end_dt = start_dt + timedelta(hours=24) args = { "data": data, "start": start_dt, "end": end_dt, "reserve": False, } if event_type != SimpleEventType.ALL: args["event_type"] = get_ufp_event(event_type) camera: Camera | None = None if camera_id != "all": camera = data.api.bootstrap.cameras.get(camera_id) args["camera_id"] = camera_id title = f"{start.strftime('%B %Y')} > {title}" events = await self._build_events(**args) # type: ignore[arg-type] source.children = events source.title = self._breadcrumb( data, title, camera=camera, event_type=event_type, count=len(events), ) return source async def _build_events_type( self, data: ProtectData, camera_id: str, event_type: SimpleEventType, build_children: bool = False, ) -> BrowseMediaSource: """Build folder media source for a selectors for a given event type.""" base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}" title = EVENT_NAME_MAP[event_type].title() source = BrowseMediaSource( domain=DOMAIN, identifier=base_id, media_class=MediaClass.DIRECTORY, media_content_type=VIDEO_FORMAT, title=title, can_play=False, can_expand=True, children_media_class=MediaClass.VIDEO, ) if not build_children or data.api.bootstrap.recording_start is None: return source children = [ self._build_recent(data, camera_id, event_type, 1), self._build_recent(data, camera_id, event_type, 7), self._build_recent(data, camera_id, event_type, 30), ] start, end = _get_month_start_end(data.api.bootstrap.recording_start) while end > start: children.append(self._build_month(data, camera_id, event_type, end.date())) end = (end - timedelta(days=1)).replace(day=1) camera: Camera | None = None if camera_id != "all": camera = data.api.bootstrap.cameras.get(camera_id) source.children = await asyncio.gather(*children) source.title = self._breadcrumb(data, title, camera=camera) return source async def _get_camera_thumbnail_url(self, camera: Camera) -> str | None: """Get camera thumbnail URL using the first available camera entity.""" if not camera.is_connected or camera.is_privacy_on: return None entity_id: str | None = None entity_registry = self.async_get_registry() for channel in camera.channels: # do not use the package camera if channel.id == 3: continue base_id = f"{camera.mac}_{channel.id}" entity_id = entity_registry.async_get_entity_id( Platform.CAMERA, DOMAIN, base_id ) if entity_id is None: entity_id = entity_registry.async_get_entity_id( Platform.CAMERA, DOMAIN, f"{base_id}_insecure" ) if entity_id: # verify entity is available entry = entity_registry.async_get(entity_id) if entry and not entry.disabled: break entity_id = None if entity_id is not None: url = URL(CameraImageView.url.format(entity_id=entity_id)) return str( url.update_query({"width": THUMBNAIL_WIDTH, "height": THUMBNAIL_HEIGHT}) ) return None async def _build_camera( self, data: ProtectData, camera_id: str, build_children: bool = False ) -> BrowseMediaSource: """Build media source for selectors for a UniFi Protect camera.""" name = "All Cameras" is_doorbell = data.api.bootstrap.has_doorbell has_smart = data.api.bootstrap.has_smart_detections camera: Camera | None = None if camera_id != "all": camera = data.api.bootstrap.cameras.get(camera_id) if camera is None: raise BrowseError(f"Unknown Camera ID: {camera_id}") name = camera.name or camera.market_name or camera.type is_doorbell = camera.feature_flags.is_doorbell has_smart = camera.feature_flags.has_smart_detect thumbnail_url: str | None = None if camera is not None: thumbnail_url = await self._get_camera_thumbnail_url(camera) source = BrowseMediaSource( domain=DOMAIN, identifier=f"{data.api.bootstrap.nvr.id}:browse:{camera_id}", media_class=MediaClass.DIRECTORY, media_content_type=VIDEO_FORMAT, title=name, can_play=False, can_expand=True, thumbnail=thumbnail_url, children_media_class=MediaClass.VIDEO, ) if not build_children: return source source.children = [ await self._build_events_type(data, camera_id, SimpleEventType.MOTION), ] if is_doorbell: source.children.insert( 0, await self._build_events_type(data, camera_id, SimpleEventType.RING), ) if has_smart: source.children.append( await self._build_events_type(data, camera_id, SimpleEventType.SMART) ) if is_doorbell or has_smart: source.children.insert( 0, await self._build_events_type(data, camera_id, SimpleEventType.ALL), ) source.title = self._breadcrumb(data, name) return source async def _build_cameras(self, data: ProtectData) -> list[BrowseMediaSource]: """Build media source for a single UniFi Protect NVR.""" cameras: list[BrowseMediaSource] = [await self._build_camera(data, "all")] for camera in data.get_by_types({ModelType.CAMERA}): camera = cast(Camera, camera) if not camera.can_read_media(data.api.bootstrap.auth_user): continue cameras.append(await self._build_camera(data, camera.id)) return cameras async def _build_console(self, data: ProtectData) -> BrowseMediaSource: """Build media source for a single UniFi Protect NVR.""" base = BrowseMediaSource( domain=DOMAIN, identifier=f"{data.api.bootstrap.nvr.id}:browse", media_class=MediaClass.DIRECTORY, media_content_type=VIDEO_FORMAT, title=data.api.bootstrap.nvr.name, can_play=False, can_expand=True, children_media_class=MediaClass.VIDEO, children=await self._build_cameras(data), ) return base async def _build_sources(self) -> BrowseMediaSource: """Return all media source for all UniFi Protect NVRs.""" consoles: list[BrowseMediaSource] = [] for data_source in self.data_sources.values(): if not data_source.api.bootstrap.has_media: continue console_source = await self._build_console(data_source) consoles.append(console_source) if len(consoles) == 1: return consoles[0] return BrowseMediaSource( domain=DOMAIN, identifier=None, media_class=MediaClass.DIRECTORY, media_content_type=VIDEO_FORMAT, title=self.name, can_play=False, can_expand=True, children_media_class=MediaClass.VIDEO, children=consoles, )