mirror of https://github.com/home-assistant/core
Provide most media metadata in DlnaDmrEntity (#56728)
Co-authored-by: Steven Looman <steven.looman@gmail.com>
This commit is contained in:
parent
718f8d8bf7
commit
f7d95588f8
|
@ -1,8 +1,12 @@
|
|||
"""Constants for the DLNA DMR component."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.media_player import const as _mp_const
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DOMAIN: Final = "dlna_dmr"
|
||||
|
@ -14,3 +18,43 @@ CONF_POLL_AVAILABILITY: Final = "poll_availability"
|
|||
DEFAULT_NAME: Final = "DLNA Digital Media Renderer"
|
||||
|
||||
CONNECT_TIMEOUT: Final = 10
|
||||
|
||||
# Map UPnP class to media_player media_content_type
|
||||
MEDIA_TYPE_MAP: Mapping[str, str] = {
|
||||
"object": _mp_const.MEDIA_TYPE_URL,
|
||||
"object.item": _mp_const.MEDIA_TYPE_URL,
|
||||
"object.item.imageItem": _mp_const.MEDIA_TYPE_IMAGE,
|
||||
"object.item.imageItem.photo": _mp_const.MEDIA_TYPE_IMAGE,
|
||||
"object.item.audioItem": _mp_const.MEDIA_TYPE_MUSIC,
|
||||
"object.item.audioItem.musicTrack": _mp_const.MEDIA_TYPE_MUSIC,
|
||||
"object.item.audioItem.audioBroadcast": _mp_const.MEDIA_TYPE_MUSIC,
|
||||
"object.item.audioItem.audioBook": _mp_const.MEDIA_TYPE_PODCAST,
|
||||
"object.item.videoItem": _mp_const.MEDIA_TYPE_VIDEO,
|
||||
"object.item.videoItem.movie": _mp_const.MEDIA_TYPE_MOVIE,
|
||||
"object.item.videoItem.videoBroadcast": _mp_const.MEDIA_TYPE_TVSHOW,
|
||||
"object.item.videoItem.musicVideoClip": _mp_const.MEDIA_TYPE_VIDEO,
|
||||
"object.item.playlistItem": _mp_const.MEDIA_TYPE_PLAYLIST,
|
||||
"object.item.textItem": _mp_const.MEDIA_TYPE_URL,
|
||||
"object.item.bookmarkItem": _mp_const.MEDIA_TYPE_URL,
|
||||
"object.item.epgItem": _mp_const.MEDIA_TYPE_EPISODE,
|
||||
"object.item.epgItem.audioProgram": _mp_const.MEDIA_TYPE_EPISODE,
|
||||
"object.item.epgItem.videoProgram": _mp_const.MEDIA_TYPE_EPISODE,
|
||||
"object.container": _mp_const.MEDIA_TYPE_PLAYLIST,
|
||||
"object.container.person": _mp_const.MEDIA_TYPE_ARTIST,
|
||||
"object.container.person.musicArtist": _mp_const.MEDIA_TYPE_ARTIST,
|
||||
"object.container.playlistContainer": _mp_const.MEDIA_TYPE_PLAYLIST,
|
||||
"object.container.album": _mp_const.MEDIA_TYPE_ALBUM,
|
||||
"object.container.album.musicAlbum": _mp_const.MEDIA_TYPE_ALBUM,
|
||||
"object.container.album.photoAlbum": _mp_const.MEDIA_TYPE_ALBUM,
|
||||
"object.container.genre": _mp_const.MEDIA_TYPE_GENRE,
|
||||
"object.container.genre.musicGenre": _mp_const.MEDIA_TYPE_GENRE,
|
||||
"object.container.genre.movieGenre": _mp_const.MEDIA_TYPE_GENRE,
|
||||
"object.container.channelGroup": _mp_const.MEDIA_TYPE_CHANNELS,
|
||||
"object.container.channelGroup.audioChannelGroup": _mp_const.MEDIA_TYPE_CHANNELS,
|
||||
"object.container.channelGroup.videoChannelGroup": _mp_const.MEDIA_TYPE_CHANNELS,
|
||||
"object.container.epgContainer": _mp_const.MEDIA_TYPE_TVSHOW,
|
||||
"object.container.storageSystem": _mp_const.MEDIA_TYPE_PLAYLIST,
|
||||
"object.container.storageVolume": _mp_const.MEDIA_TYPE_PLAYLIST,
|
||||
"object.container.storageFolder": _mp_const.MEDIA_TYPE_PLAYLIST,
|
||||
"object.container.bookmarkFolder": _mp_const.MEDIA_TYPE_PLAYLIST,
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"name": "DLNA Digital Media Renderer",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"requirements": ["async-upnp-client==0.22.3"],
|
||||
"requirements": ["async-upnp-client==0.22.4"],
|
||||
"dependencies": ["network", "ssdp"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
|
|
@ -37,7 +37,6 @@ from homeassistant.const import (
|
|||
STATE_ON,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry, entity_registry
|
||||
|
@ -51,6 +50,7 @@ from .const import (
|
|||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
LOGGER as _LOGGER,
|
||||
MEDIA_TYPE_MAP,
|
||||
)
|
||||
from .data import EventListenAddr, get_domain_data
|
||||
|
||||
|
@ -389,11 +389,6 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
domain_data = get_domain_data(self.hass)
|
||||
await domain_data.async_release_event_notifier(self._event_addr)
|
||||
|
||||
@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
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Retrieve the latest data."""
|
||||
if not self._device:
|
||||
|
@ -426,6 +421,44 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
self.check_available = True
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@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 unique_id(self) -> str:
|
||||
"""Report the UDN (Unique Device Name) as this entity's unique ID."""
|
||||
return self.udn
|
||||
|
||||
@property
|
||||
def usn(self) -> str:
|
||||
"""Get the USN based on the UDN (Unique Device Name) and device type."""
|
||||
return f"{self.udn}::{self.device_type}"
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""State of the player."""
|
||||
if not self._device or not self.available:
|
||||
return STATE_OFF
|
||||
if self._device.transport_state is None:
|
||||
return STATE_ON
|
||||
if self._device.transport_state in (
|
||||
TransportState.PLAYING,
|
||||
TransportState.TRANSITIONING,
|
||||
):
|
||||
return STATE_PLAYING
|
||||
if self._device.transport_state in (
|
||||
TransportState.PAUSED_PLAYBACK,
|
||||
TransportState.PAUSED_RECORDING,
|
||||
):
|
||||
return STATE_PAUSED
|
||||
if self._device.transport_state == TransportState.VENDOR_DEFINED:
|
||||
# Unable to map this state to anything reasonable, so it's "Unknown"
|
||||
return None
|
||||
|
||||
return STATE_IDLE
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Flag media player features that are supported at this moment.
|
||||
|
@ -552,7 +585,8 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
"""Title of current playing media."""
|
||||
if not self._device:
|
||||
return None
|
||||
return self._device.media_title
|
||||
# Use the best available title
|
||||
return self._device.media_program_title or self._device.media_title
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
|
@ -562,26 +596,18 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
return self._device.media_image_url
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""State of the player."""
|
||||
if not self._device or not self.available:
|
||||
return STATE_OFF
|
||||
if self._device.transport_state is None:
|
||||
return STATE_ON
|
||||
if self._device.transport_state in (
|
||||
TransportState.PLAYING,
|
||||
TransportState.TRANSITIONING,
|
||||
):
|
||||
return STATE_PLAYING
|
||||
if self._device.transport_state in (
|
||||
TransportState.PAUSED_PLAYBACK,
|
||||
TransportState.PAUSED_RECORDING,
|
||||
):
|
||||
return STATE_PAUSED
|
||||
if self._device.transport_state == TransportState.VENDOR_DEFINED:
|
||||
return STATE_UNKNOWN
|
||||
def media_content_id(self) -> str | None:
|
||||
"""Content ID of current playing media."""
|
||||
if not self._device:
|
||||
return None
|
||||
return self._device.current_track_uri
|
||||
|
||||
return STATE_IDLE
|
||||
@property
|
||||
def media_content_type(self) -> str | None:
|
||||
"""Content type of current playing media."""
|
||||
if not self._device or not self._device.media_class:
|
||||
return None
|
||||
return MEDIA_TYPE_MAP.get(self._device.media_class)
|
||||
|
||||
@property
|
||||
def media_duration(self) -> int | None:
|
||||
|
@ -608,11 +634,80 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
return self._device.media_position_updated_at
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Report the UDN (Unique Device Name) as this entity's unique ID."""
|
||||
return self.udn
|
||||
def media_artist(self) -> str | None:
|
||||
"""Artist of current playing media, music track only."""
|
||||
if not self._device:
|
||||
return None
|
||||
return self._device.media_artist
|
||||
|
||||
@property
|
||||
def usn(self) -> str:
|
||||
"""Get the USN based on the UDN (Unique Device Name) and device type."""
|
||||
return f"{self.udn}::{self.device_type}"
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Album name of current playing media, music track only."""
|
||||
if not self._device:
|
||||
return None
|
||||
return self._device.media_album_name
|
||||
|
||||
@property
|
||||
def media_album_artist(self) -> str | None:
|
||||
"""Album artist of current playing media, music track only."""
|
||||
if not self._device:
|
||||
return None
|
||||
return self._device.media_album_artist
|
||||
|
||||
@property
|
||||
def media_track(self) -> int | None:
|
||||
"""Track number of current playing media, music track only."""
|
||||
if not self._device:
|
||||
return None
|
||||
return self._device.media_track_number
|
||||
|
||||
@property
|
||||
def media_series_title(self) -> str | None:
|
||||
"""Title of series of current playing media, TV show only."""
|
||||
if not self._device:
|
||||
return None
|
||||
return self._device.media_series_title
|
||||
|
||||
@property
|
||||
def media_season(self) -> str | None:
|
||||
"""Season number, starting at 1, of current playing media, TV show only."""
|
||||
if not self._device:
|
||||
return None
|
||||
# Some DMRs, like Kodi, leave this as 0 and encode the season & episode
|
||||
# in the episode_number metadata, as {season:d}{episode:02d}
|
||||
if (
|
||||
not self._device.media_season_number
|
||||
or self._device.media_season_number == "0"
|
||||
) and self._device.media_episode_number:
|
||||
try:
|
||||
episode = int(self._device.media_episode_number, 10)
|
||||
if episode > 100:
|
||||
return str(episode // 100)
|
||||
except ValueError:
|
||||
pass
|
||||
return self._device.media_season_number
|
||||
|
||||
@property
|
||||
def media_episode(self) -> str | None:
|
||||
"""Episode number of current playing media, TV show only."""
|
||||
if not self._device:
|
||||
return None
|
||||
# Complement to media_season math above
|
||||
if (
|
||||
not self._device.media_season_number
|
||||
or self._device.media_season_number == "0"
|
||||
) and self._device.media_episode_number:
|
||||
try:
|
||||
episode = int(self._device.media_episode_number, 10)
|
||||
if episode > 100:
|
||||
return str(episode % 100)
|
||||
except ValueError:
|
||||
pass
|
||||
return self._device.media_episode_number
|
||||
|
||||
@property
|
||||
def media_channel(self) -> str | None:
|
||||
"""Channel name currently playing."""
|
||||
if not self._device:
|
||||
return None
|
||||
return self._device.media_channel_name
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"domain": "ssdp",
|
||||
"name": "Simple Service Discovery Protocol (SSDP)",
|
||||
"documentation": "https://www.home-assistant.io/integrations/ssdp",
|
||||
"requirements": ["async-upnp-client==0.22.3"],
|
||||
"requirements": ["async-upnp-client==0.22.4"],
|
||||
"dependencies": ["network"],
|
||||
"after_dependencies": ["zeroconf"],
|
||||
"codeowners": [],
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"name": "UPnP/IGD",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/upnp",
|
||||
"requirements": ["async-upnp-client==0.22.3"],
|
||||
"requirements": ["async-upnp-client==0.22.4"],
|
||||
"dependencies": ["network", "ssdp"],
|
||||
"codeowners": ["@StevenLooman","@ehendrix23"],
|
||||
"ssdp": [
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"domain": "yeelight",
|
||||
"name": "Yeelight",
|
||||
"documentation": "https://www.home-assistant.io/integrations/yeelight",
|
||||
"requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.3"],
|
||||
"requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.4"],
|
||||
"codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["network"],
|
||||
|
|
|
@ -4,7 +4,7 @@ aiodiscover==1.4.2
|
|||
aiohttp==3.7.4.post0
|
||||
aiohttp_cors==0.7.0
|
||||
astral==2.2
|
||||
async-upnp-client==0.22.3
|
||||
async-upnp-client==0.22.4
|
||||
async_timeout==3.0.1
|
||||
attrs==21.2.0
|
||||
awesomeversion==21.8.1
|
||||
|
|
|
@ -330,7 +330,7 @@ asterisk_mbox==0.5.0
|
|||
# homeassistant.components.ssdp
|
||||
# homeassistant.components.upnp
|
||||
# homeassistant.components.yeelight
|
||||
async-upnp-client==0.22.3
|
||||
async-upnp-client==0.22.4
|
||||
|
||||
# homeassistant.components.supla
|
||||
asyncpysupla==0.0.5
|
||||
|
|
|
@ -224,7 +224,7 @@ arcam-fmj==0.7.0
|
|||
# homeassistant.components.ssdp
|
||||
# homeassistant.components.upnp
|
||||
# homeassistant.components.yeelight
|
||||
async-upnp-client==0.22.3
|
||||
async-upnp-client==0.22.4
|
||||
|
||||
# homeassistant.components.aurora
|
||||
auroranoaa==0.0.2
|
||||
|
|
|
@ -2,9 +2,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from collections.abc import AsyncIterable, Mapping
|
||||
from datetime import timedelta
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, DEFAULT, Mock, patch
|
||||
|
||||
from async_upnp_client.exceptions import UpnpConnectionError, UpnpError
|
||||
|
@ -73,6 +74,8 @@ async def mock_entity_id(
|
|||
"""
|
||||
entity_id = await setup_mock_component(hass, config_entry_mock)
|
||||
|
||||
assert dmr_device_mock.async_subscribe_services.await_count == 1
|
||||
|
||||
yield entity_id
|
||||
|
||||
# Unload config entry to clean up
|
||||
|
@ -97,6 +100,8 @@ async def mock_disconnected_entity_id(
|
|||
|
||||
entity_id = await setup_mock_component(hass, config_entry_mock)
|
||||
|
||||
assert dmr_device_mock.async_subscribe_services.await_count == 0
|
||||
|
||||
yield entity_id
|
||||
|
||||
# Unload config entry to clean up
|
||||
|
@ -239,7 +244,6 @@ async def test_setup_entry_with_options(
|
|||
|
||||
async def test_event_subscribe_failure(
|
||||
hass: HomeAssistant,
|
||||
domain_data_mock: Mock,
|
||||
config_entry_mock: MockConfigEntry,
|
||||
dmr_device_mock: Mock,
|
||||
) -> None:
|
||||
|
@ -310,11 +314,15 @@ async def test_available_device(
|
|||
await async_update_entity(hass, mock_entity_id)
|
||||
|
||||
# Check attributes come directly from the device
|
||||
entity_state = hass.states.get(mock_entity_id)
|
||||
assert entity_state is not None
|
||||
attrs = entity_state.attributes
|
||||
assert attrs is not None
|
||||
async def get_attrs() -> Mapping[str, Any]:
|
||||
await async_update_entity(hass, mock_entity_id)
|
||||
entity_state = hass.states.get(mock_entity_id)
|
||||
assert entity_state is not None
|
||||
attrs = entity_state.attributes
|
||||
assert attrs is not None
|
||||
return attrs
|
||||
|
||||
attrs = await get_attrs()
|
||||
assert attrs[mp_const.ATTR_MEDIA_VOLUME_LEVEL] is dmr_device_mock.volume_level
|
||||
assert attrs[mp_const.ATTR_MEDIA_VOLUME_MUTED] is dmr_device_mock.is_volume_muted
|
||||
assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration
|
||||
|
@ -323,9 +331,43 @@ async def test_available_device(
|
|||
attrs[mp_const.ATTR_MEDIA_POSITION_UPDATED_AT]
|
||||
is dmr_device_mock.media_position_updated_at
|
||||
)
|
||||
assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title
|
||||
assert attrs[mp_const.ATTR_MEDIA_CONTENT_ID] is dmr_device_mock.current_track_uri
|
||||
assert attrs[mp_const.ATTR_MEDIA_ARTIST] is dmr_device_mock.media_artist
|
||||
assert attrs[mp_const.ATTR_MEDIA_ALBUM_NAME] is dmr_device_mock.media_album_name
|
||||
assert attrs[mp_const.ATTR_MEDIA_ALBUM_ARTIST] is dmr_device_mock.media_album_artist
|
||||
assert attrs[mp_const.ATTR_MEDIA_TRACK] is dmr_device_mock.media_track_number
|
||||
assert attrs[mp_const.ATTR_MEDIA_SERIES_TITLE] is dmr_device_mock.media_series_title
|
||||
assert attrs[mp_const.ATTR_MEDIA_SEASON] is dmr_device_mock.media_season_number
|
||||
assert attrs[mp_const.ATTR_MEDIA_EPISODE] is dmr_device_mock.media_episode_number
|
||||
assert attrs[mp_const.ATTR_MEDIA_CHANNEL] is dmr_device_mock.media_channel_name
|
||||
# Entity picture is cached, won't correspond to remote image
|
||||
assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str)
|
||||
# media_title depends on what is available
|
||||
assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_program_title
|
||||
dmr_device_mock.media_program_title = None
|
||||
attrs = await get_attrs()
|
||||
assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title
|
||||
# media_content_type is mapped from UPnP class to MediaPlayer type
|
||||
dmr_device_mock.media_class = "object.item.audioItem.musicTrack"
|
||||
attrs = await get_attrs()
|
||||
assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MUSIC
|
||||
dmr_device_mock.media_class = "object.item.videoItem.movie"
|
||||
attrs = await get_attrs()
|
||||
assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MOVIE
|
||||
dmr_device_mock.media_class = "object.item.videoItem.videoBroadcast"
|
||||
attrs = await get_attrs()
|
||||
assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_TVSHOW
|
||||
# media_season & media_episode have a special case
|
||||
dmr_device_mock.media_season_number = "0"
|
||||
dmr_device_mock.media_episode_number = "123"
|
||||
attrs = await get_attrs()
|
||||
assert attrs[mp_const.ATTR_MEDIA_SEASON] == "1"
|
||||
assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "23"
|
||||
dmr_device_mock.media_season_number = "0"
|
||||
dmr_device_mock.media_episode_number = "S1E23" # Unexpected and not parsed
|
||||
attrs = await get_attrs()
|
||||
assert attrs[mp_const.ATTR_MEDIA_SEASON] == "0"
|
||||
assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "S1E23"
|
||||
|
||||
# Check supported feature flags, one at a time.
|
||||
# tuple(async_upnp_client feature check property, HA feature flag)
|
||||
|
@ -688,7 +730,6 @@ async def test_multiple_ssdp_alive(
|
|||
domain_data_mock: Mock,
|
||||
ssdp_scanner_mock: Mock,
|
||||
mock_disconnected_entity_id: str,
|
||||
dmr_device_mock: Mock,
|
||||
) -> None:
|
||||
"""Test multiple SSDP alive notifications is ok, only connects to device once."""
|
||||
domain_data_mock.upnp_factory.async_create_device.reset_mock()
|
||||
|
@ -1028,7 +1069,6 @@ async def test_ssdp_bootid(
|
|||
|
||||
async def test_become_unavailable(
|
||||
hass: HomeAssistant,
|
||||
domain_data_mock: Mock,
|
||||
mock_entity_id: str,
|
||||
dmr_device_mock: Mock,
|
||||
) -> None:
|
||||
|
@ -1226,7 +1266,6 @@ async def test_config_update_connect_failure(
|
|||
hass: HomeAssistant,
|
||||
domain_data_mock: Mock,
|
||||
config_entry_mock: MockConfigEntry,
|
||||
dmr_device_mock: Mock,
|
||||
mock_entity_id: str,
|
||||
) -> None:
|
||||
"""Test DlnaDmrEntity gracefully handles connect failure after config change."""
|
||||
|
|
Loading…
Reference in New Issue