core/homeassistant/components/unifiprotect/media_source.py

891 lines
30 KiB
Python

"""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 uiprotect.data import Camera, Event, EventType, SmartDetectObjectType
from uiprotect.exceptions import NvrError
from uiprotect.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 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, async_get_ufp_entries
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"
AUDIO = "audio"
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: dict[SimpleEventType, set[EventType]] = {
SimpleEventType.ALL: {
EventType.RING,
EventType.MOTION,
EventType.SMART_DETECT,
EventType.SMART_DETECT_LINE,
EventType.SMART_AUDIO_DETECT,
},
SimpleEventType.RING: {EventType.RING},
SimpleEventType.MOTION: {EventType.MOTION},
SimpleEventType.SMART: {EventType.SMART_DETECT, EventType.SMART_DETECT_LINE},
SimpleEventType.AUDIO: {EventType.SMART_AUDIO_DETECT},
}
EVENT_NAME_MAP = {
SimpleEventType.ALL: "All Events",
SimpleEventType.RING: "Ring Events",
SimpleEventType.MOTION: "Motion Events",
SimpleEventType.SMART: "Object Detections",
SimpleEventType.AUDIO: "Audio Detections",
}
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
"""Set up UniFi Protect media source."""
return ProtectMediaSource(
hass,
{
entry.runtime_data.api.bootstrap.nvr.id: entry.runtime_data
for entry in async_get_ufp_entries(hass)
},
)
@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()
@callback
def _get_object_name(event: Event | dict[str, Any]) -> str:
if isinstance(event, Event):
event = event.unifi_dict()
names = []
types = set(event["smartDetectTypes"])
metadata = event.get("metadata") or {}
for thumb in metadata.get("detectedThumbnails", []):
thumb_type = thumb.get("type")
if thumb_type not in types:
continue
types.remove(thumb_type)
if thumb_type == SmartDetectObjectType.VEHICLE.value:
attributes = thumb.get("attributes") or {}
color = attributes.get("color", {}).get("val", "")
vehicle_type = attributes.get("vehicleType", {}).get("val", "vehicle")
license_plate = metadata.get("licensePlate", {}).get("name")
name = f"{color} {vehicle_type}".strip().title()
if license_plate:
types.remove(SmartDetectObjectType.LICENSE_PLATE.value)
name = f"{name}: {license_plate}"
names.append(name)
else:
smart_type = SmartDetectObjectType(thumb_type)
names.append(smart_type.name.title().replace("_", " "))
for raw in types:
smart_type = SmartDetectObjectType(raw)
names.append(smart_type.name.title().replace("_", " "))
return ", ".join(sorted(names))
@callback
def _get_audio_name(event: Event | dict[str, Any]) -> str:
if isinstance(event, Event):
event = event.unifi_dict()
smart_types = [SmartDetectObjectType(e) for e in event["smartDetectTypes"]]
return ", ".join([s.name.title().replace("_", " ") for s in smart_types])
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}"
return f"{data.api.bootstrap.nvr.display_name} > {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 = EventType(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 in EVENT_MAP[SimpleEventType.RING]:
event_text = "Ring Event"
elif event_type in EVENT_MAP[SimpleEventType.MOTION]:
event_text = "Motion Event"
elif event_type in EVENT_MAP[SimpleEventType.SMART]:
event_text = f"Object Detection - {_get_object_name(event)}"
elif event_type in EVENT_MAP[SimpleEventType.AUDIO]:
event_text = f"Audio Detection - {_get_audio_name(event)}"
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_types: set[EventType] | None = None,
reserve: bool = False,
) -> list[BrowseMediaSource]:
"""Build media source for a given range of time and event type."""
event_types = event_types or EVENT_MAP[SimpleEventType.ALL]
types = list(event_types)
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()
camera: Camera | None = None
event_camera_id: str | None = None
if camera_id != "all":
camera = data.api.bootstrap.cameras.get(camera_id)
event_camera_id = camera_id
events = await self._build_events(
data=data,
start=now - timedelta(days=days),
end=now,
camera_id=event_camera_id,
event_types=EVENT_MAP[event_type],
reserve=True,
)
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.get_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)
camera: Camera | None = None
event_camera_id: str | None = None
if camera_id != "all":
camera = data.api.bootstrap.cameras.get(camera_id)
event_camera_id = camera_id
title = f"{start.strftime('%B %Y')} > {title}"
events = await self._build_events(
data=data,
start=start_dt,
end=end_dt,
camera_id=event_camera_id,
reserve=False,
event_types=EVENT_MAP[event_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)
)
source.children.append(
await self._build_events_type(data, camera_id, SimpleEventType.AUDIO)
)
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_cameras():
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."""
return 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),
)
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,
)