コード例 #1
0
    async def async_browse_media(
        self,
        item: MediaSourceItem,
    ) -> BrowseMediaSource:
        """Return library media for Music Assistent instance."""
        mass = self.get_mass()

        if mass is None:
            raise Unresolvable("MusicAssistant is not initialized")

        if item is None or item.identifier is None:
            return self._build_main_listing()
        if item.identifier == LIBRARY_ARTISTS:
            return await self._build_artists_listing(mass)
        if item.identifier == LIBRARY_ALBUMS:
            return await self._build_albums_listing(mass)
        if item.identifier == LIBRARY_TRACKS:
            return await self._build_tracks_listing(mass)
        if item.identifier == LIBRARY_PLAYLISTS:
            return await self._build_playlists_listing(mass)
        if item.identifier == LIBRARY_RADIO:
            return await self._build_radio_listing(mass)
        if "artist" in item.identifier:
            return await self._build_artist_items_listing(
                mass, item.identifier)
        if "album" in item.identifier:
            return await self._build_album_items_listing(mass, item.identifier)
        if "playlist" in item.identifier:
            return await self._build_playlist_items_listing(
                mass, item.identifier)

        raise Unresolvable(f"Unknown identifier: {item.identifier}")
コード例 #2
0
    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        """Resolve media to a url."""
        parsed = URL(item.identifier)
        if "message" not in parsed.query:
            raise Unresolvable("No message specified.")

        options = dict(parsed.query)
        kwargs = {
            "engine": parsed.name,
            "message": options.pop("message"),
            "language": options.pop("language", None),
            "options": options,
        }

        manager: SpeechManager = self.hass.data[DOMAIN]

        try:
            url = await manager.async_get_url_path(**kwargs
                                                   )  # type: ignore[arg-type]
        except HomeAssistantError as err:
            raise Unresolvable(str(err)) from err

        mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg"

        return PlayMedia(url, mime_type)
コード例 #3
0
    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        """Resolve media to a url."""
        parsed = URL(item.identifier)
        if "message" not in parsed.query:
            raise Unresolvable("No message specified.")

        options = dict(parsed.query)
        kwargs: dict[str, Any] = {
            "engine": parsed.name,
            "message": options.pop("message"),
            "language": options.pop("language", None),
            "options": options,
        }
        if "cache" in options:
            kwargs["cache"] = options.pop("cache") == "true"

        manager: SpeechManager = self.hass.data[DOMAIN]

        try:
            url = await manager.async_get_url_path(**kwargs)
        except HomeAssistantError as err:
            raise Unresolvable(str(err)) from err

        mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg"

        if manager.base_url and manager.base_url != get_url(self.hass):
            url = f"{manager.base_url}{url}"

        return PlayMedia(url, mime_type)
コード例 #4
0
 async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
     """Resolve media identifier to a url."""
     media_id: MediaId | None = parse_media_id(item.identifier)
     if not media_id:
         raise Unresolvable("No identifier specified for MediaSourceItem")
     devices = await self.devices()
     if not (device := devices.get(media_id.device_id)):
         raise Unresolvable("Unable to find device with identifier: %s" %
                            item.identifier)
コード例 #5
0
ファイル: dms.py プロジェクト: cgtobi/home-assistant
    async def async_resolve_path(self, path: str) -> str:
        """Return an Object ID resolved from a path string."""
        assert self._device

        # Iterate through the path, searching for a matching title within the
        # DLNA object hierarchy.
        object_id = ROOT_OBJECT_ID
        for node in path.split(PATH_SEP):
            if not node:
                # Skip empty names, for when multiple slashes are involved, e.g //
                continue

            criteria = (
                f'@parentID="{_esc_quote(object_id)}" and dc:title="{_esc_quote(node)}"'
            )
            try:
                result = await self._device.async_search_directory(
                    object_id,
                    search_criteria=criteria,
                    metadata_filter=DLNA_PATH_FILTER,
                    requested_count=1,
                )
            except UpnpActionError as err:
                LOGGER.debug("Error in call to async_search_directory: %r",
                             err)
                if err.error_code == ContentDirectoryErrorCode.NO_SUCH_CONTAINER:
                    raise Unresolvable(
                        f"No such container: {object_id}") from err
                # Search failed, but can still try browsing children
            else:
                if result.total_matches > 1:
                    raise Unresolvable(
                        f"Too many items found for {node} in {path}")

                if result.result:
                    object_id = result.result[0].id
                    continue

            # Nothing was found via search, fall back to iterating children
            result = await self._device.async_browse_direct_children(
                object_id, metadata_filter=DLNA_PATH_FILTER)

            if result.total_matches == 0 or not result.result:
                raise Unresolvable(f"No contents for {node} in {path}")

            node_lower = node.lower()
            for child in result.result:
                if child.title.lower() == node_lower:
                    object_id = child.id
                    break
            else:
                # Examining all direct children failed too
                raise Unresolvable(f"Nothing found for {node} in {path}")
        return object_id
コード例 #6
0
    def async_parse_identifier(self, item: MediaSourceItem) -> Tuple[str, str]:
        """Parse identifier."""
        if not item.identifier:
            # Empty source_dir_id and location
            return "", ""

        source_dir_id, location = item.identifier.split("/", 1)
        if source_dir_id not in self.hass.config.media_dirs:
            raise Unresolvable("Unknown source directory.")

        if location != sanitize_path(location):
            raise Unresolvable("Invalid path.")

        return source_dir_id, location
コード例 #7
0
ファイル: media_source.py プロジェクト: rikroe/core
    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        """Resolve selected Radio station to a streaming URL."""
        radios = self.radios

        if radios is None:
            raise Unresolvable("Radio Browser not initialized")

        station = await radios.station(uuid=item.identifier)
        if not station:
            raise Unresolvable("Radio station is no longer available")

        if not (mime_type := self._async_get_station_mime_type(station)):
            raise Unresolvable(
                "Could not determine stream type of radio station")
コード例 #8
0
ファイル: media_source.py プロジェクト: dgomes/hass-motioneye
    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        """Resolve media to a url."""
        config_id, device_id, kind, path = self._parse_identifier(
            item.identifier)

        if not config_id or not device_id or not kind or not path:
            raise Unresolvable(
                f"Incomplete media identifier specified: {item.identifier}")

        config = self._get_config_or_raise(config_id)
        device = self._get_device_or_raise(device_id)
        camera_id = self._get_camera_id_or_raise(config, device)
        self._verify_kind_or_raise(kind)
        path = self._get_path_or_raise(path)

        client = self.hass.data[DOMAIN][config.entry_id][CONF_CLIENT]
        try:
            if kind == "movies":
                url = client.get_movie_url(camera_id, path)
            else:
                url = client.get_image_url(camera_id, path)
        except MotionEyeClientPathError as exc:
            raise Unresolvable from exc

        return PlayMedia(url, MIME_TYPE_MAP[kind])
コード例 #9
0
def async_parse_identifier(item: MediaSourceItem) -> Tuple[str, str]:
    """Parse identifier."""
    if not item.identifier:
        source_dir_id = "media"
        location = ""

    else:
        source_dir_id, location = item.identifier.lstrip("/").split("/", 1)

    if source_dir_id != "media":
        raise Unresolvable("Unknown source directory.")

    if location != sanitize_path(location):
        raise Unresolvable("Invalid path.")

    return source_dir_id, location
コード例 #10
0
    def async_parse_identifier(self, item: MediaSourceItem) -> tuple[str, str]:
        """Parse identifier."""
        if not item.identifier:
            # Empty source_dir_id and location
            return "", ""

        source_dir_id, location = item.identifier.split("/", 1)
        if source_dir_id not in self.hass.config.media_dirs:
            raise Unresolvable("Unknown source directory.")

        try:
            raise_if_invalid_path(location)
        except ValueError as err:
            raise Unresolvable("Invalid path.") from err

        return source_dir_id, location
コード例 #11
0
class NestMediaSource(MediaSource):
    """Provide Nest Media Sources for Nest Cameras.

    The media source generates a directory tree of devices and media associated
    with events for each device (e.g. motion, person, etc). Each node in the
    tree has a unique MediaId.

    The lifecycle for event media is handled outside of NestMediaSource, and
    instead it just asks the device for all events it knows about.
    """

    name: str = MEDIA_SOURCE_TITLE

    def __init__(self, hass: HomeAssistant) -> None:
        """Initialize NestMediaSource."""
        super().__init__(DOMAIN)
        self.hass = hass

    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        """Resolve media identifier to a url."""
        media_id: MediaId | None = parse_media_id(item.identifier)
        if not media_id:
            raise Unresolvable("No identifier specified for MediaSourceItem")
        devices = await self.devices()
        if not (device := devices.get(media_id.device_id)):
            raise Unresolvable("Unable to find device with identifier: %s" %
                               item.identifier)
        if not media_id.event_token:
            # The device resolves to the most recent event if available
            if not (last_event_id := await _async_get_recent_event_id(
                    media_id, device)):
                raise Unresolvable(
                    "Unable to resolve recent event for device: %s" %
                    item.identifier)
            media_id = last_event_id
コード例 #12
0
    async def async_resolve_media(self, item: MediaSourceItem) -> str:
        """Resolve media to a url."""
        source_dir_id, location = self.async_parse_identifier(item)
        if source_dir_id == "" or source_dir_id not in self.hass.config.media_dirs:
            raise Unresolvable("Unknown source directory.")

        mime_type, _ = mimetypes.guess_type(
            str(self.async_full_path(source_dir_id, location)))
        return PlayMedia(f"/media/{item.identifier}", mime_type)
コード例 #13
0
    async def async_resolve_media(self,
                                  item: MediaSourceItem) -> DidlPlayMedia:
        """Resolve a media item to a playable item."""
        dms_data = get_domain_data(self.hass)
        if not dms_data.sources:
            raise Unresolvable("No sources have been configured")

        source_id, media_id = _parse_identifier(item)
        if not source_id:
            raise Unresolvable(f"No source ID in {item.identifier}")
        if not media_id:
            raise Unresolvable(f"No media ID in {item.identifier}")

        try:
            source = dms_data.sources[source_id]
        except KeyError as err:
            raise Unresolvable(f"Unknown source ID: {source_id}") from err

        return await source.async_resolve_media(media_id)
コード例 #14
0
ファイル: dms.py プロジェクト: cgtobi/home-assistant
    async def async_resolve_search(self, query: str) -> DidlPlayMedia:
        """Return first playable media item found by the query string."""
        assert self._device

        result = await self._device.async_search_directory(
            container_id=ROOT_OBJECT_ID,
            search_criteria=query,
            metadata_filter=DLNA_RESOLVE_FILTER,
            requested_count=1,
        )

        if result.total_matches == 0 or not result.result:
            raise Unresolvable(f"Nothing found for {query}")

        # Use the first result, even if it doesn't have a playable resource
        item = result.result[0]

        if not isinstance(item, didl_lite.DidlObject):
            raise Unresolvable(f"{item} is not a DidlObject")

        return self._didl_to_play_media(item)
コード例 #15
0
    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        """Resolve media to a url."""
        component: EntityComponent = self.hass.data[DOMAIN]
        camera = cast(Optional[Camera], component.get_entity(item.identifier))

        if not camera:
            raise Unresolvable(
                f"Could not resolve media item: {item.identifier}")

        if (stream_type := camera.frontend_stream_type) is None:
            return PlayMedia(f"/api/camera_proxy_stream/{camera.entity_id}",
                             camera.content_type)
コード例 #16
0
 async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
     """Resolve media to a url."""
     identifier = Identifier.from_str(
         item.identifier,
         default_frigate_instance_id=self._get_default_frigate_instance_id(),
     )
     if identifier:
         server_path = identifier.get_integration_proxy_path()
         return PlayMedia(
             f"/api/frigate/{identifier.frigate_instance_id}/{server_path}",
             identifier.mime_type,
         )
     raise Unresolvable("Unknown identifier: %s" % item.identifier)
コード例 #17
0
    def async_parse_identifier(self, item: MediaSourceItem) -> ItemInfo:
        """Parse identifier."""
        if not item.identifier:
            raise Unresolvable("Invalid path.")

        split = item.identifier.split("/")

        entry_id = split[0]

        api = self.hass.data[DOMAIN].get(entry_id)
        if api is None:
            raise Unresolvable(f"Missing {DOMAIN} configuration entry.")

        iteminfo = ItemInfo(entry_id, api.client.target_dir,
                            split[1] if len(split) > 1 else None)

        try:
            raise_if_invalid_path(str(iteminfo.path))
        except ValueError as err:
            raise Unresolvable("Invalid path.") from err

        return iteminfo
コード例 #18
0
    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        """Resolve media to a url."""
        config_id, device_id, kind, path = self._parse_identifier(item.identifier)

        if not config_id or not device_id or not kind or not path:
            raise Unresolvable(
                f"Incomplete media identifier specified: {item.identifier}"
            )

        config = self._get_config_or_raise(config_id)
        device = self._get_device_or_raise(device_id)
        self._verify_kind_or_raise(kind)

        url = get_media_url(
            self.hass.data[DOMAIN][config.entry_id][CONF_CLIENT],
            self._get_camera_id_or_raise(config, device),
            self._get_path_or_raise(path),
            kind == "images",
        )
        if not url:
            raise Unresolvable(f"Could not resolve media item: {item.identifier}")

        return PlayMedia(url, MIME_TYPE_MAP[kind])
コード例 #19
0
class CameraMediaSource(MediaSource):
    """Provide camera feeds as media sources."""

    name: str = "Camera"

    def __init__(self, hass: HomeAssistant) -> None:
        """Initialize CameraMediaSource."""
        super().__init__(DOMAIN)
        self.hass = hass

    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        """Resolve media to a url."""
        component: EntityComponent = self.hass.data[DOMAIN]
        camera = cast(Optional[Camera], component.get_entity(item.identifier))

        if not camera:
            raise Unresolvable(
                f"Could not resolve media item: {item.identifier}")

        if (stream_type := camera.frontend_stream_type) is None:
            return PlayMedia(f"/api/camera_proxy_stream/{camera.entity_id}",
                             camera.content_type)

        if stream_type != STREAM_TYPE_HLS:
            raise Unresolvable(
                "Camera does not support MJPEG or HLS streaming.")

        if "stream" not in self.hass.config.components:
            raise Unresolvable("Stream integration not loaded")

        try:
            url = await _async_stream_endpoint_url(self.hass, camera,
                                                   HLS_PROVIDER)
        except HomeAssistantError as err:
            raise Unresolvable(str(err)) from err

        return PlayMedia(url, FORMAT_CONTENT_TYPE[HLS_PROVIDER])
コード例 #20
0
ファイル: dms.py プロジェクト: cgtobi/home-assistant
    def _didl_to_play_media(self, item: didl_lite.DidlObject) -> DidlPlayMedia:
        """Return the first playable resource from a DIDL-Lite object."""
        assert self._device

        if not item.res:
            LOGGER.debug("Object %s has no resources", item.id)
            raise Unresolvable("Object has no resources")

        for resource in item.res:
            if not resource.uri:
                continue
            if mime_type := _resource_mime_type(resource):
                url = self._device.get_absolute_url(resource.uri)
                LOGGER.debug("Resolved to url %s MIME %s", url, mime_type)
                return DidlPlayMedia(url, mime_type, item)
コード例 #21
0
    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        """Resolve media to a url."""
        mass = self.get_mass()

        if mass is None:
            raise Unresolvable("MusicAssistant is not initialized")

        if item.target_media_player is None:
            # TODO: How to intercept a play request for the 'webbrowser' player
            # or at least hide our source for the webbrowser player ?
            raise Unresolvable("Playback not supported on the device.")

        # get/create mass player instance attached to this entity id
        player = await async_register_player_control(self.hass, mass,
                                                     item.target_media_player)
        if not player:
            return PlayMedia(item.identifier, MEDIA_TYPE_MUSIC)

        # send the mass library uri to the player(queue)
        await player.active_queue.play_media(item.identifier, passive=True)
        # tell the actual player to play the stream url
        content_type = player.active_queue.settings.stream_type.value
        return PlayMedia(player.active_queue.stream.url,
                         f"audio/{content_type}")
コード例 #22
0
def async_parse_identifier(
    item: MediaSourceItem, ) -> tuple[str, str, int | None]:
    """Parse identifier."""
    if not item.identifier:
        return "events", "", None

    source, path = item.identifier.lstrip("/").split("/", 1)

    if source != "events":
        raise Unresolvable("Unknown source directory.")

    if "/" in path:
        camera_id, event_id = path.split("/", 1)
        return source, camera_id, int(event_id)

    return source, path, None
コード例 #23
0
ファイル: dms.py プロジェクト: cgtobi/home-assistant
    async def async_resolve_media(self, identifier: str) -> DidlPlayMedia:
        """Resolve a media item to a playable item."""
        LOGGER.debug("async_resolve_media(%s)", identifier)
        action, parameters = _parse_identifier(identifier)

        if action is Action.OBJECT:
            return await self.async_resolve_object(parameters)

        if action is Action.PATH:
            object_id = await self.async_resolve_path(parameters)
            return await self.async_resolve_object(object_id)

        if action is Action.SEARCH:
            return await self.async_resolve_search(parameters)

        LOGGER.debug("Invalid identifier %s", identifier)
        raise Unresolvable(f"Invalid identifier {identifier}")
コード例 #24
0
class NestMediaSource(MediaSource):
    """Provide Nest Media Sources for Nest Cameras.

    The media source generates a directory tree of devices and media associated
    with events for each device (e.g. motion, person, etc). Each node in the
    tree has a unique MediaId.

    The lifecycle for event media is handled outside of NestMediaSource, and
    instead it just asks the device for all events it knows about.
    """

    name: str = MEDIA_SOURCE_TITLE

    def __init__(self, hass: HomeAssistant) -> None:
        """Initialize NestMediaSource."""
        super().__init__(DOMAIN)
        self.hass = hass

    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        """Resolve media identifier to a url."""
        media_id: MediaId | None = parse_media_id(item.identifier)
        if not media_id:
            raise Unresolvable("No identifier specified for MediaSourceItem")
        if not media_id.event_id:
            raise Unresolvable("Identifier missing an event_id: %s" % item.identifier)
        devices = await self.devices()
        if not (device := devices.get(media_id.device_id)):
            raise Unresolvable(
                "Unable to find device with identifier: %s" % item.identifier
            )
        events = await _get_events(device)
        if media_id.event_id not in events:
            raise Unresolvable(
                "Unable to find event with identifier: %s" % item.identifier
            )
        event = events[media_id.event_id]
        return PlayMedia(
            EVENT_MEDIA_API_URL_FORMAT.format(
                device_id=media_id.device_id, event_id=media_id.event_id
            ),
            event.event_image_type.content_type,
        )
コード例 #25
0
ファイル: dms.py プロジェクト: cgtobi/home-assistant
class DmsDeviceSource:
    """DMS Device wrapper, providing media files as a media_source."""

    hass: HomeAssistant
    config_entry: ConfigEntry

    # Unique slug used for media-source URIs
    source_id: str

    # Last known URL for the device, used when adding this wrapper to hass to
    # try to connect before SSDP has rediscovered it, or when SSDP discovery
    # fails.
    location: str | None

    _device_lock: asyncio.Lock  # Held when connecting or disconnecting the device
    _device: DmsDevice | None = None

    # Only try to connect once when an ssdp:alive advertisement is received
    _ssdp_connect_failed: bool = False

    # Track BOOTID in SSDP advertisements for device changes
    _bootid: int | None = None

    def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry,
                 source_id: str) -> None:
        """Initialize a DMS Source."""
        self.hass = hass
        self.config_entry = config_entry
        self.source_id = source_id
        self.location = self.config_entry.data[CONF_URL]
        self._device_lock = asyncio.Lock()

    # Callbacks and events

    async def async_added_to_hass(self) -> None:
        """Handle addition of this source."""

        # Try to connect to the last known location, but don't worry if not available
        if not self._device and self.location:
            try:
                await self.device_connect()
            except UpnpError as err:
                LOGGER.debug("Couldn't connect immediately: %r", err)

        # Get SSDP notifications for only this device
        self.config_entry.async_on_unload(await ssdp.async_register_callback(
            self.hass, self.async_ssdp_callback, {"USN": self.usn}))

        # async_upnp_client.SsdpListener only reports byebye once for each *UDN*
        # (device name) which often is not the USN (service within the device)
        # that we're interested in. So also listen for byebye advertisements for
        # the UDN, which is reported in the _udn field of the combined_headers.
        self.config_entry.async_on_unload(await ssdp.async_register_callback(
            self.hass,
            self.async_ssdp_callback,
            {
                "_udn": self.udn,
                "NTS": NotificationSubType.SSDP_BYEBYE
            },
        ))

    async def async_will_remove_from_hass(self) -> None:
        """Handle removal of this source."""
        await self.device_disconnect()

    async def async_ssdp_callback(self, info: ssdp.SsdpServiceInfo,
                                  change: ssdp.SsdpChange) -> None:
        """Handle notification from SSDP of device state change."""
        LOGGER.debug(
            "SSDP %s notification of device %s at %s",
            change,
            info.ssdp_usn,
            info.ssdp_location,
        )

        try:
            bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_BOOTID]
            bootid: int | None = int(bootid_str, 10)
        except (KeyError, ValueError):
            bootid = None

        if change == ssdp.SsdpChange.UPDATE:
            # This is an announcement that bootid is about to change
            if self._bootid is not None and self._bootid == bootid:
                # Store the new value (because our old value matches) so that we
                # can ignore subsequent ssdp:alive messages
                try:
                    next_bootid_str = info.ssdp_headers[
                        ssdp.ATTR_SSDP_NEXTBOOTID]
                    self._bootid = int(next_bootid_str, 10)
                except (KeyError, ValueError):
                    pass
            # Nothing left to do until ssdp:alive comes through
            return

        if self._bootid is not None and self._bootid != bootid:
            # Device has rebooted
            # Maybe connection will succeed now
            self._ssdp_connect_failed = False
            if self._device:
                # Drop existing connection and maybe reconnect
                await self.device_disconnect()
        self._bootid = bootid

        if change == ssdp.SsdpChange.BYEBYE:
            # Device is going away
            if self._device:
                # Disconnect from gone device
                await self.device_disconnect()
            # Maybe the next alive message will result in a successful connection
            self._ssdp_connect_failed = False

        if (change == ssdp.SsdpChange.ALIVE and not self._device
                and not self._ssdp_connect_failed):
            assert info.ssdp_location
            self.location = info.ssdp_location
            try:
                await self.device_connect()
            except UpnpError as err:
                self._ssdp_connect_failed = True
                LOGGER.warning(
                    "Failed connecting to recently alive device at %s: %r",
                    self.location,
                    err,
                )

    # Device connection/disconnection

    async def device_connect(self) -> None:
        """Connect to the device now that it's available."""
        LOGGER.debug("Connecting to device at %s", self.location)

        async with self._device_lock:
            if self._device:
                LOGGER.debug("Trying to connect when device already connected")
                return

            if not self.location:
                LOGGER.debug("Not connecting because location is not known")
                return

            domain_data = get_domain_data(self.hass)

            # Connect to the base UPNP device
            upnp_device = await domain_data.upnp_factory.async_create_device(
                self.location)

            # Create profile wrapper
            self._device = DmsDevice(upnp_device, domain_data.event_handler)

            # Update state variables. We don't care if they change, so this is
            # only done once, here.
            await self._device.async_update()

    async def device_disconnect(self) -> None:
        """Destroy connections to the device now that it's not available.

        Also call when removing this device wrapper from hass to clean up connections.
        """
        async with self._device_lock:
            if not self._device:
                LOGGER.debug("Disconnecting from device that's not connected")
                return

            LOGGER.debug("Disconnecting from %s", self._device.name)

            self._device = None

    # Device properties

    @property
    def available(self) -> bool:
        """Device is available when we have a connection to it."""
        return self._device is not None and self._device.profile_device.available

    @property
    def usn(self) -> str:
        """Get the USN (Unique Service Name) for the wrapped UPnP device end-point."""
        return self.config_entry.data[CONF_DEVICE_ID]

    @property
    def udn(self) -> str:
        """Get the UDN (Unique Device Name) based on the USN."""
        return self.usn.partition("::")[0]

    @property
    def name(self) -> str:
        """Return a name for the media server."""
        return self.config_entry.title

    @property
    def icon(self) -> str | None:
        """Return an URL to an icon for the media server."""
        if not self._device:
            return None

        return self._device.icon

    # MediaSource methods

    async def async_resolve_media(self, identifier: str) -> DidlPlayMedia:
        """Resolve a media item to a playable item."""
        LOGGER.debug("async_resolve_media(%s)", identifier)
        action, parameters = _parse_identifier(identifier)

        if action is Action.OBJECT:
            return await self.async_resolve_object(parameters)

        if action is Action.PATH:
            object_id = await self.async_resolve_path(parameters)
            return await self.async_resolve_object(object_id)

        if action is Action.SEARCH:
            return await self.async_resolve_search(parameters)

        LOGGER.debug("Invalid identifier %s", identifier)
        raise Unresolvable(f"Invalid identifier {identifier}")

    async def async_browse_media(self,
                                 identifier: str | None) -> BrowseMediaSource:
        """Browse media."""
        LOGGER.debug("async_browse_media(%s)", identifier)
        action, parameters = _parse_identifier(identifier)

        if action is Action.OBJECT:
            return await self.async_browse_object(parameters)

        if action is Action.PATH:
            object_id = await self.async_resolve_path(parameters)
            return await self.async_browse_object(object_id)

        if action is Action.SEARCH:
            return await self.async_browse_search(parameters)

        return await self.async_browse_object(ROOT_OBJECT_ID)

    # DMS methods

    @catch_request_errors
    async def async_resolve_object(self, object_id: str) -> DidlPlayMedia:
        """Return a playable media item specified by ObjectID."""
        assert self._device

        item = await self._device.async_browse_metadata(
            object_id, metadata_filter=DLNA_RESOLVE_FILTER)

        # Use the first playable resource
        return self._didl_to_play_media(item)

    @catch_request_errors
    async def async_resolve_path(self, path: str) -> str:
        """Return an Object ID resolved from a path string."""
        assert self._device

        # Iterate through the path, searching for a matching title within the
        # DLNA object hierarchy.
        object_id = ROOT_OBJECT_ID
        for node in path.split(PATH_SEP):
            if not node:
                # Skip empty names, for when multiple slashes are involved, e.g //
                continue

            criteria = (
                f'@parentID="{_esc_quote(object_id)}" and dc:title="{_esc_quote(node)}"'
            )
            try:
                result = await self._device.async_search_directory(
                    object_id,
                    search_criteria=criteria,
                    metadata_filter=DLNA_PATH_FILTER,
                    requested_count=1,
                )
            except UpnpActionError as err:
                LOGGER.debug("Error in call to async_search_directory: %r",
                             err)
                if err.error_code == ContentDirectoryErrorCode.NO_SUCH_CONTAINER:
                    raise Unresolvable(
                        f"No such container: {object_id}") from err
                # Search failed, but can still try browsing children
            else:
                if result.total_matches > 1:
                    raise Unresolvable(
                        f"Too many items found for {node} in {path}")

                if result.result:
                    object_id = result.result[0].id
                    continue

            # Nothing was found via search, fall back to iterating children
            result = await self._device.async_browse_direct_children(
                object_id, metadata_filter=DLNA_PATH_FILTER)

            if result.total_matches == 0 or not result.result:
                raise Unresolvable(f"No contents for {node} in {path}")

            node_lower = node.lower()
            for child in result.result:
                if child.title.lower() == node_lower:
                    object_id = child.id
                    break
            else:
                # Examining all direct children failed too
                raise Unresolvable(f"Nothing found for {node} in {path}")
        return object_id

    @catch_request_errors
    async def async_resolve_search(self, query: str) -> DidlPlayMedia:
        """Return first playable media item found by the query string."""
        assert self._device

        result = await self._device.async_search_directory(
            container_id=ROOT_OBJECT_ID,
            search_criteria=query,
            metadata_filter=DLNA_RESOLVE_FILTER,
            requested_count=1,
        )

        if result.total_matches == 0 or not result.result:
            raise Unresolvable(f"Nothing found for {query}")

        # Use the first result, even if it doesn't have a playable resource
        item = result.result[0]

        if not isinstance(item, didl_lite.DidlObject):
            raise Unresolvable(f"{item} is not a DidlObject")

        return self._didl_to_play_media(item)

    @catch_request_errors
    async def async_browse_object(self, object_id: str) -> BrowseMediaSource:
        """Return the contents of a DLNA container by ObjectID."""
        assert self._device

        base_object = await self._device.async_browse_metadata(
            object_id, metadata_filter=DLNA_BROWSE_FILTER)

        children = await self._device.async_browse_direct_children(
            object_id,
            metadata_filter=DLNA_BROWSE_FILTER,
            sort_criteria=self._sort_criteria,
        )

        return self._didl_to_media_source(base_object, children)

    @catch_request_errors
    async def async_browse_search(self, query: str) -> BrowseMediaSource:
        """Return all media items found by the query string."""
        assert self._device

        result = await self._device.async_search_directory(
            container_id=ROOT_OBJECT_ID,
            search_criteria=query,
            metadata_filter=DLNA_BROWSE_FILTER,
        )

        children = [
            self._didl_to_media_source(child) for child in result.result
            if isinstance(child, didl_lite.DidlObject)
        ]

        media_source = BrowseMediaSource(
            domain=DOMAIN,
            identifier=self._make_identifier(Action.SEARCH, query),
            media_class=MEDIA_CLASS_DIRECTORY,
            media_content_type="",
            title="Search results",
            can_play=False,
            can_expand=True,
            children=children,
        )

        if media_source.children:
            media_source.calculate_children_class()

        return media_source

    def _didl_to_play_media(self, item: didl_lite.DidlObject) -> DidlPlayMedia:
        """Return the first playable resource from a DIDL-Lite object."""
        assert self._device

        if not item.res:
            LOGGER.debug("Object %s has no resources", item.id)
            raise Unresolvable("Object has no resources")

        for resource in item.res:
            if not resource.uri:
                continue
            if mime_type := _resource_mime_type(resource):
                url = self._device.get_absolute_url(resource.uri)
                LOGGER.debug("Resolved to url %s MIME %s", url, mime_type)
                return DidlPlayMedia(url, mime_type, item)

        LOGGER.debug("Object %s has no playable resources", item.id)
        raise Unresolvable("Object has no playable resources")