async def _build_music_library( self, library: dict[str, Any], include_children: bool) -> BrowseMediaSource: """Return a single music library as a browsable media source.""" library_id = library[ITEM_KEY_ID] library_name = library[ITEM_KEY_NAME] result = BrowseMediaSource( domain=DOMAIN, identifier=library_id, media_class=MEDIA_CLASS_DIRECTORY, media_content_type=MEDIA_TYPE_NONE, title=library_name, can_play=False, can_expand=True, ) if include_children: result.children_media_class = MEDIA_CLASS_ARTIST result.children = await self._build_artists( library_id) # type: ignore[assignment] if not result.children: result.children_media_class = MEDIA_CLASS_ALBUM result.children = await self._build_albums( library_id) # type: ignore[assignment] return result
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=MEDIA_CLASS_DIRECTORY, media_content_type="video/mp4", title=title, can_play=False, can_expand=True, children_media_class=MEDIA_CLASS_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=MEDIA_CLASS_DIRECTORY, media_content_type=VIDEO_FORMAT, title=title, can_play=False, can_expand=True, children_media_class=MEDIA_CLASS_VIDEO, ) if not build_children: return source month = start.month children = [ self._build_days(data, camera_id, event_type, start, is_all=True) ] while start.month == month: 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 async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: """Browse media.""" dms_data = get_domain_data(self.hass) if not dms_data.sources: raise BrowseError("No sources have been configured") source_id, media_id = _parse_identifier(item) LOGGER.debug("Browsing for %s / %s", source_id, media_id) if not source_id and len(dms_data.sources) > 1: # Browsing the root of dlna_dms with more than one server, return # all known servers. base = BrowseMediaSource( domain=DOMAIN, identifier="", media_class=MEDIA_CLASS_DIRECTORY, media_content_type=MEDIA_TYPE_CHANNELS, title=self.name, can_play=False, can_expand=True, children_media_class=MEDIA_CLASS_CHANNEL, ) base.children = [ BrowseMediaSource( domain=DOMAIN, identifier= f"{source_id}/{PATH_OBJECT_ID_FLAG}{ROOT_OBJECT_ID}", media_class=MEDIA_CLASS_CHANNEL, media_content_type=MEDIA_TYPE_CHANNEL, title=source.name, can_play=False, can_expand=True, thumbnail=source.icon, ) for source_id, source in dms_data.sources.items() ] return base if not source_id: # No source specified, default to the first registered source_id = next(iter(dms_data.sources)) try: source = dms_data.sources[source_id] except KeyError as err: raise BrowseError(f"Unknown source ID: {source_id}") from err return await source.async_browse_media(media_id)
def _build_item_response(self, iteminfo: ItemInfo, path: Path, is_child=False): mime_type, _ = mimetypes.guess_type(str(path)) is_file = path.is_file() is_dir = path.is_dir() # Make sure it's a file or directory if not is_file and not is_dir: return None # Check that it's a media file if is_file and (not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES): return None title = path.name if is_dir: title += "/" media_class = MEDIA_CLASS_MAP.get( mime_type and mime_type.split("/")[0], MEDIA_CLASS_DIRECTORY) media = BrowseMediaSource( domain=DOMAIN, identifier= f"{iteminfo.entry_id}/{path.relative_to(iteminfo.target_dir)}", media_class=media_class, media_content_type=mime_type or "", title=title, can_play=is_file, can_expand=is_dir, ) if is_file or is_child: return media # Append first level children media.children = [] for child_path in path.iterdir(): child = self._build_item_response(iteminfo, child_path, True) if child: media.children.append(child) # Sort children showing directories first, then by name media.children.sort(key=lambda child: (child.can_play, child.title)) return media
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=MEDIA_CLASS_DIRECTORY, media_content_type=VIDEO_FORMAT, title=title, can_play=False, can_expand=True, children_media_class=MEDIA_CLASS_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_start_end(self.hass, 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 _build_libraries(self) -> BrowseMediaSource: """Return all supported libraries the user has access to as media sources.""" base = BrowseMediaSource( domain=DOMAIN, identifier=None, media_class=MEDIA_CLASS_DIRECTORY, media_content_type=MEDIA_TYPE_NONE, title=self.name, can_play=False, can_expand=True, children_media_class=MEDIA_CLASS_DIRECTORY, ) libraries = await self._get_libraries() base.children = [] for library in libraries: base.children.append(await self._build_library(library, False)) return base
async def async_browse_media( self, item: MediaSourceItem, media_types: tuple[str] = MEDIA_MIME_TYPES) -> BrowseMediaSource: # root node for motion_frontend media # add a child for each configured server if item.identifier is None: base = BrowseMediaSource( domain=DOMAIN, identifier="", media_class=MEDIA_CLASS_DIRECTORY, media_content_type=None, title=self.name, can_play=False, can_expand=True, children_media_class=MEDIA_CLASS_DIRECTORY, ) base.children = [ BrowseMediaSource( domain=DOMAIN, identifier=entry_id, media_class=MEDIA_CLASS_DIRECTORY, media_content_type=None, title=api.client.unique_id, can_play=False, can_expand=True, ) for entry_id, api in self.hass.data[DOMAIN].items() ] return base try: iteminfo: ItemInfo = self.async_parse_identifier(item) except Unresolvable as err: raise BrowseError(str(err)) from err return await self.hass.async_add_executor_job(self._browse_media, iteminfo)
async def _build_movie_library( self, library: dict[str, Any], include_children: bool ) -> BrowseMediaSource: """Return a single movie library as a browsable media source.""" library_id = library[ITEM_KEY_ID] library_name = library[ITEM_KEY_NAME] result = BrowseMediaSource( domain=DOMAIN, identifier=library_id, media_class=MEDIA_CLASS_DIRECTORY, media_content_type=MEDIA_TYPE_NONE, title=library_name, can_play=False, can_expand=True, ) if include_children: result.children_media_class = MEDIA_CLASS_MOVIE result.children = await self._build_movies(library_id) return result
async def _build_album(self, album: dict[str, Any], include_children: bool) -> BrowseMediaSource: """Return a single album as a browsable media source.""" album_id = album[ITEM_KEY_ID] album_title = album[ITEM_KEY_NAME] thumbnail_url = self._get_thumbnail_url(album) result = BrowseMediaSource( domain=DOMAIN, identifier=album_id, media_class=MEDIA_CLASS_ALBUM, media_content_type=MEDIA_TYPE_NONE, title=album_title, can_play=False, can_expand=True, thumbnail=thumbnail_url, ) if include_children: result.children_media_class = MEDIA_CLASS_TRACK result.children = await self._build_tracks( album_id) # type: ignore[assignment] return result
async def _build_artist( self, artist: dict[str, Any], include_children: bool ) -> BrowseMediaSource: """Return a single artist as a browsable media source.""" artist_id = artist[ITEM_KEY_ID] artist_name = artist[ITEM_KEY_NAME] thumbnail_url = self._get_thumbnail_url(artist) result = BrowseMediaSource( domain=DOMAIN, identifier=artist_id, media_class=MEDIA_CLASS_ARTIST, media_content_type=MEDIA_TYPE_NONE, title=artist_name, can_play=False, can_expand=True, thumbnail=thumbnail_url, ) if include_children: result.children_media_class = MEDIA_CLASS_ALBUM result.children = await self._build_albums(artist_id) return result
async def _async_browse_media( self, source: str, camera_id: str, event_id: str = None, no_descend: bool = True) -> BrowseMediaSource: """ actual browse after input validation """ event: typings.VodEvent = None cache: typings.MediaSourceCacheEntry = None start_date = None if camera_id and camera_id in self.cache: cache = self.cache[camera_id] if cache and event_id: if "playback_events" in cache and event_id in cache[ "playback_events"]: event = cache["playback_events"][event_id] end_date = event["end"] start_date = event["start"] time = start_date.time() duration = end_date - start_date title = f"{time} {duration}" else: year, *rest = event_id.split("/", 3) month = rest[0] if len(rest) > 0 else None day = rest[1] if len(rest) > 1 else None start_date = dt.datetime.combine( dt.date(int(year), int(month) if month else 1, int(day) if day else 1), dt.time.min, dt_utils.now().tzinfo, ) title = f"{start_date.date()}" path = f"{source}/{camera_id}/{event_id}" else: if cache is None: camera_id = "" title = NAME else: title = cache["name"] path = f"{source}/{camera_id}" media_class = MEDIA_CLASS_DIRECTORY if event is None else MEDIA_CLASS_VIDEO media = BrowseMediaSource( domain=DOMAIN, identifier=path, media_class=media_class, media_content_type=MEDIA_TYPE_VIDEO, title=title, can_play=bool(not event is None and event.get("file")), can_expand=event is None, ) if not event is None and cache.get("playback_thumbnails", False): url = "/api/" + DOMAIN + f"/media_proxy/{camera_id}/{event_id}" # TODO : I cannot find a way to get the current user context at this point # so I will have to leave the view as unauthenticated, as a temporary # security measure, I will add a unique token to the event to limit # "exposure" # url = async_sign_path(self.hass, None, url, dt.timedelta(minutes=30)) if "token" not in event: event["token"] = secrets.token_hex() media.thumbnail = f"{url}?token={parse.quote_plus(event['token'])}" if not media.can_play and not media.can_expand: _LOGGER.debug("Camera %s with event %s without media url found", camera_id, event_id) raise IncompatibleMediaSource if not media.can_expand or no_descend: return media media.children = [] base: ReolinkBase = None if cache is None: for entry_id in self.hass.data[DOMAIN]: entry = self.hass.data[DOMAIN][entry_id] if not isinstance(entry, dict) or not BASE in entry: continue base = entry[BASE] camera_id = base.unique_id cache = self.cache.get(camera_id, None) if cache is None: cache = self.cache[camera_id] = { "entry_id": entry_id, "unique_id": base.unique_id, "playback_events": {}, } cache["name"] = base.name child = await self._async_browse_media(source, camera_id) media.children.append(child) return media base = self.hass.data[DOMAIN][cache["entry_id"]][BASE] # TODO: the cache is one way so over time it can grow and have invalid # records, the code should be expanded to invalidate/expire # entries if base is None: raise BrowseError("Camera does not exist.") if not start_date: if ("playback_day_entries" not in cache or cache.get("playback_months", -1) != base.playback_months): end_date = dt_utils.now() start_date = dt.datetime.combine(end_date.date(), dt.time.min) cache["playback_months"] = base.playback_months if cache["playback_months"] > 1: start_date -= relativedelta.relativedelta( months=int(cache["playback_months"])) entries = cache["playback_day_entries"] = [] search, _ = await base.api.send_search(start_date, end_date, True) if not search is None: for status in search: year = status["year"] month = status["mon"] for day, flag in enumerate(status["table"], start=1): if flag == "1": entries.append(dt.date(year, month, day)) entries.sort() else: entries = cache["playback_day_entries"] for date in cache["playback_day_entries"]: child = await self._async_browse_media( source, camera_id, f"{date.year}/{date.month}/{date.day}") media.children.append(child) return media cache["playback_thumbnails"] = base.playback_thumbnails end_date = dt.datetime.combine(start_date.date(), dt.time.max, start_date.tzinfo) _, files = await base.api.send_search(start_date, end_date) if not files is None: events = cache.setdefault("playback_events", {}) for file in files: dto = file["EndTime"] end_date = dt.datetime( dto["year"], dto["mon"], dto["day"], dto["hour"], dto["min"], dto["sec"], 0, end_date.tzinfo, ) dto = file["StartTime"] start_date = dt.datetime( dto["year"], dto["mon"], dto["day"], dto["hour"], dto["min"], dto["sec"], 0, end_date.tzinfo, ) event_id = str(start_date.timestamp()) event = events.setdefault(event_id, {}) event["start"] = start_date event["end"] = end_date event["file"] = file["name"] child = await self._async_browse_media(source, camera_id, event_id) media.children.append(child) return media
def _build_item_response( self, source: str, camera_id: str, event_id: int | None = None ) -> BrowseMediaSource: if event_id and event_id in self.events[camera_id]: created = dt.datetime.fromtimestamp(event_id) if self.events[camera_id][event_id]["type"] == "outdoor": thumbnail = ( self.events[camera_id][event_id]["event_list"][0] .get("snapshot", {}) .get("url") ) message = remove_html_tags( self.events[camera_id][event_id]["event_list"][0]["message"] ) else: thumbnail = ( self.events[camera_id][event_id].get("snapshot", {}).get("url") ) message = remove_html_tags(self.events[camera_id][event_id]["message"]) title = f"{created} - {message}" else: title = self.hass.data[DOMAIN][DATA_CAMERAS].get(camera_id, MANUFACTURER) thumbnail = None if event_id: path = f"{source}/{camera_id}/{event_id}" else: path = f"{source}/{camera_id}" media_class = MEDIA_CLASS_DIRECTORY if event_id is None else MEDIA_CLASS_VIDEO media = BrowseMediaSource( domain=DOMAIN, identifier=path, media_class=media_class, media_content_type=MEDIA_TYPE_VIDEO, title=title, can_play=bool( event_id and self.events[camera_id][event_id].get("media_url") ), can_expand=event_id is None, thumbnail=thumbnail, ) if not media.can_play and not media.can_expand: _LOGGER.debug( "Camera %s with event %s without media url found", camera_id, event_id ) raise IncompatibleMediaSource if not media.can_expand: return media media.children = [] # Append first level children if not camera_id: for cid in self.events: child = self._build_item_response(source, cid) if child: media.children.append(child) else: for eid in self.events[camera_id]: try: child = self._build_item_response(source, camera_id, eid) except IncompatibleMediaSource: continue if child: media.children.append(child) return media
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.has_chime 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=MEDIA_CLASS_DIRECTORY, media_content_type=VIDEO_FORMAT, title=name, can_play=False, can_expand=True, thumbnail=thumbnail_url, children_media_class=MEDIA_CLASS_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_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=MEDIA_CLASS_DIRECTORY, media_content_type=VIDEO_FORMAT, title=title, can_play=False, can_expand=True, children_media_class=MEDIA_CLASS_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