Provide most media metadata in DlnaDmrEntity (#56728)

Co-authored-by: Steven Looman <steven.looman@gmail.com>
This commit is contained in:
Michael Chisholm 2021-09-29 10:37:23 +10:00 committed by GitHub
parent 718f8d8bf7
commit f7d95588f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 227 additions and 49 deletions

View File

@ -1,8 +1,12 @@
"""Constants for the DLNA DMR component.""" """Constants for the DLNA DMR component."""
from __future__ import annotations
from collections.abc import Mapping
import logging import logging
from typing import Final from typing import Final
from homeassistant.components.media_player import const as _mp_const
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
DOMAIN: Final = "dlna_dmr" DOMAIN: Final = "dlna_dmr"
@ -14,3 +18,43 @@ CONF_POLL_AVAILABILITY: Final = "poll_availability"
DEFAULT_NAME: Final = "DLNA Digital Media Renderer" DEFAULT_NAME: Final = "DLNA Digital Media Renderer"
CONNECT_TIMEOUT: Final = 10 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,
}

View File

@ -3,7 +3,7 @@
"name": "DLNA Digital Media Renderer", "name": "DLNA Digital Media Renderer",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "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"], "dependencies": ["network", "ssdp"],
"ssdp": [ "ssdp": [
{ {

View File

@ -37,7 +37,6 @@ from homeassistant.const import (
STATE_ON, STATE_ON,
STATE_PAUSED, STATE_PAUSED,
STATE_PLAYING, STATE_PLAYING,
STATE_UNKNOWN,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers import device_registry, entity_registry
@ -51,6 +50,7 @@ from .const import (
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
LOGGER as _LOGGER, LOGGER as _LOGGER,
MEDIA_TYPE_MAP,
) )
from .data import EventListenAddr, get_domain_data from .data import EventListenAddr, get_domain_data
@ -389,11 +389,6 @@ class DlnaDmrEntity(MediaPlayerEntity):
domain_data = get_domain_data(self.hass) domain_data = get_domain_data(self.hass)
await domain_data.async_release_event_notifier(self._event_addr) 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: async def async_update(self) -> None:
"""Retrieve the latest data.""" """Retrieve the latest data."""
if not self._device: if not self._device:
@ -426,6 +421,44 @@ class DlnaDmrEntity(MediaPlayerEntity):
self.check_available = True self.check_available = True
self.schedule_update_ha_state() 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 @property
def supported_features(self) -> int: def supported_features(self) -> int:
"""Flag media player features that are supported at this moment. """Flag media player features that are supported at this moment.
@ -552,7 +585,8 @@ class DlnaDmrEntity(MediaPlayerEntity):
"""Title of current playing media.""" """Title of current playing media."""
if not self._device: if not self._device:
return None return None
return self._device.media_title # Use the best available title
return self._device.media_program_title or self._device.media_title
@property @property
def media_image_url(self) -> str | None: def media_image_url(self) -> str | None:
@ -562,26 +596,18 @@ class DlnaDmrEntity(MediaPlayerEntity):
return self._device.media_image_url return self._device.media_image_url
@property @property
def state(self) -> str: def media_content_id(self) -> str | None:
"""State of the player.""" """Content ID of current playing media."""
if not self._device or not self.available: if not self._device:
return STATE_OFF return None
if self._device.transport_state is None: return self._device.current_track_uri
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
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 @property
def media_duration(self) -> int | None: def media_duration(self) -> int | None:
@ -608,11 +634,80 @@ class DlnaDmrEntity(MediaPlayerEntity):
return self._device.media_position_updated_at return self._device.media_position_updated_at
@property @property
def unique_id(self) -> str: def media_artist(self) -> str | None:
"""Report the UDN (Unique Device Name) as this entity's unique ID.""" """Artist of current playing media, music track only."""
return self.udn if not self._device:
return None
return self._device.media_artist
@property @property
def usn(self) -> str: def media_album_name(self) -> str | None:
"""Get the USN based on the UDN (Unique Device Name) and device type.""" """Album name of current playing media, music track only."""
return f"{self.udn}::{self.device_type}" 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

View File

@ -2,7 +2,7 @@
"domain": "ssdp", "domain": "ssdp",
"name": "Simple Service Discovery Protocol (SSDP)", "name": "Simple Service Discovery Protocol (SSDP)",
"documentation": "https://www.home-assistant.io/integrations/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"], "dependencies": ["network"],
"after_dependencies": ["zeroconf"], "after_dependencies": ["zeroconf"],
"codeowners": [], "codeowners": [],

View File

@ -3,7 +3,7 @@
"name": "UPnP/IGD", "name": "UPnP/IGD",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/upnp", "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"], "dependencies": ["network", "ssdp"],
"codeowners": ["@StevenLooman","@ehendrix23"], "codeowners": ["@StevenLooman","@ehendrix23"],
"ssdp": [ "ssdp": [

View File

@ -2,7 +2,7 @@
"domain": "yeelight", "domain": "yeelight",
"name": "Yeelight", "name": "Yeelight",
"documentation": "https://www.home-assistant.io/integrations/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"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"],
"config_flow": true, "config_flow": true,
"dependencies": ["network"], "dependencies": ["network"],

View File

@ -4,7 +4,7 @@ aiodiscover==1.4.2
aiohttp==3.7.4.post0 aiohttp==3.7.4.post0
aiohttp_cors==0.7.0 aiohttp_cors==0.7.0
astral==2.2 astral==2.2
async-upnp-client==0.22.3 async-upnp-client==0.22.4
async_timeout==3.0.1 async_timeout==3.0.1
attrs==21.2.0 attrs==21.2.0
awesomeversion==21.8.1 awesomeversion==21.8.1

View File

@ -330,7 +330,7 @@ asterisk_mbox==0.5.0
# homeassistant.components.ssdp # homeassistant.components.ssdp
# homeassistant.components.upnp # homeassistant.components.upnp
# homeassistant.components.yeelight # homeassistant.components.yeelight
async-upnp-client==0.22.3 async-upnp-client==0.22.4
# homeassistant.components.supla # homeassistant.components.supla
asyncpysupla==0.0.5 asyncpysupla==0.0.5

View File

@ -224,7 +224,7 @@ arcam-fmj==0.7.0
# homeassistant.components.ssdp # homeassistant.components.ssdp
# homeassistant.components.upnp # homeassistant.components.upnp
# homeassistant.components.yeelight # homeassistant.components.yeelight
async-upnp-client==0.22.3 async-upnp-client==0.22.4
# homeassistant.components.aurora # homeassistant.components.aurora
auroranoaa==0.0.2 auroranoaa==0.0.2

View File

@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncIterable from collections.abc import AsyncIterable, Mapping
from datetime import timedelta from datetime import timedelta
from types import MappingProxyType from types import MappingProxyType
from typing import Any
from unittest.mock import ANY, DEFAULT, Mock, patch from unittest.mock import ANY, DEFAULT, Mock, patch
from async_upnp_client.exceptions import UpnpConnectionError, UpnpError 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) entity_id = await setup_mock_component(hass, config_entry_mock)
assert dmr_device_mock.async_subscribe_services.await_count == 1
yield entity_id yield entity_id
# Unload config entry to clean up # 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) entity_id = await setup_mock_component(hass, config_entry_mock)
assert dmr_device_mock.async_subscribe_services.await_count == 0
yield entity_id yield entity_id
# Unload config entry to clean up # Unload config entry to clean up
@ -239,7 +244,6 @@ async def test_setup_entry_with_options(
async def test_event_subscribe_failure( async def test_event_subscribe_failure(
hass: HomeAssistant, hass: HomeAssistant,
domain_data_mock: Mock,
config_entry_mock: MockConfigEntry, config_entry_mock: MockConfigEntry,
dmr_device_mock: Mock, dmr_device_mock: Mock,
) -> None: ) -> None:
@ -310,11 +314,15 @@ async def test_available_device(
await async_update_entity(hass, mock_entity_id) await async_update_entity(hass, mock_entity_id)
# Check attributes come directly from the device # Check attributes come directly from the device
entity_state = hass.states.get(mock_entity_id) async def get_attrs() -> Mapping[str, Any]:
assert entity_state is not None await async_update_entity(hass, mock_entity_id)
attrs = entity_state.attributes entity_state = hass.states.get(mock_entity_id)
assert attrs is not None 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_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_VOLUME_MUTED] is dmr_device_mock.is_volume_muted
assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration 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] attrs[mp_const.ATTR_MEDIA_POSITION_UPDATED_AT]
is dmr_device_mock.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 # Entity picture is cached, won't correspond to remote image
assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str) 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. # Check supported feature flags, one at a time.
# tuple(async_upnp_client feature check property, HA feature flag) # tuple(async_upnp_client feature check property, HA feature flag)
@ -688,7 +730,6 @@ async def test_multiple_ssdp_alive(
domain_data_mock: Mock, domain_data_mock: Mock,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
mock_disconnected_entity_id: str, mock_disconnected_entity_id: str,
dmr_device_mock: Mock,
) -> None: ) -> None:
"""Test multiple SSDP alive notifications is ok, only connects to device once.""" """Test multiple SSDP alive notifications is ok, only connects to device once."""
domain_data_mock.upnp_factory.async_create_device.reset_mock() domain_data_mock.upnp_factory.async_create_device.reset_mock()
@ -1028,7 +1069,6 @@ async def test_ssdp_bootid(
async def test_become_unavailable( async def test_become_unavailable(
hass: HomeAssistant, hass: HomeAssistant,
domain_data_mock: Mock,
mock_entity_id: str, mock_entity_id: str,
dmr_device_mock: Mock, dmr_device_mock: Mock,
) -> None: ) -> None:
@ -1226,7 +1266,6 @@ async def test_config_update_connect_failure(
hass: HomeAssistant, hass: HomeAssistant,
domain_data_mock: Mock, domain_data_mock: Mock,
config_entry_mock: MockConfigEntry, config_entry_mock: MockConfigEntry,
dmr_device_mock: Mock,
mock_entity_id: str, mock_entity_id: str,
) -> None: ) -> None:
"""Test DlnaDmrEntity gracefully handles connect failure after config change.""" """Test DlnaDmrEntity gracefully handles connect failure after config change."""