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."""
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,
}

View File

@ -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": [
{

View File

@ -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

View File

@ -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": [],

View File

@ -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": [

View File

@ -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"],

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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."""