From be71d626c8cb8c3e994537e20e0b35e99196755b Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 31 Mar 2021 11:37:16 -0500 Subject: [PATCH] Improve Plex device handling (#48369) --- homeassistant/components/plex/__init__.py | 30 ++++ homeassistant/components/plex/const.py | 1 + homeassistant/components/plex/media_player.py | 10 ++ tests/components/plex/test_device_handling.py | 137 ++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 tests/components/plex/test_device_handling.py diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 38825882fe93..137c0524bac5 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -24,6 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dev_reg, entity_registry as ent_reg from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import ( @@ -221,6 +222,8 @@ async def async_setup_entry(hass, entry): ) task.add_done_callback(partial(start_websocket_session, platform)) + async_cleanup_plex_devices(hass, entry) + def get_plex_account(plex_server): try: return plex_server.account @@ -261,3 +264,30 @@ async def async_options_updated(hass, entry): # Guard incomplete setup during reauth flows if server_id in hass.data[PLEX_DOMAIN][SERVERS]: hass.data[PLEX_DOMAIN][SERVERS][server_id].options = entry.options + + +@callback +def async_cleanup_plex_devices(hass, entry): + """Clean up old and invalid devices from the registry.""" + device_registry = dev_reg.async_get(hass) + entity_registry = ent_reg.async_get(hass) + + device_entries = hass.helpers.device_registry.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + + for device_entry in device_entries: + if ( + len( + hass.helpers.entity_registry.async_entries_for_device( + entity_registry, device_entry.id, include_disabled_entities=True + ) + ) + == 0 + ): + _LOGGER.debug( + "Removing orphaned device: %s / %s", + device_entry.name, + device_entry.identifiers, + ) + device_registry.async_remove_device(device_entry.id) diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index c8ab6c8f1474..e247f7a5db7b 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -4,6 +4,7 @@ from homeassistant.const import __version__ DOMAIN = "plex" NAME_FORMAT = "Plex ({})" COMMON_PLAYERS = ["Plex Web"] +TRANSIENT_DEVICE_MODELS = ["Plex Web", "Plex for Sonos"] DEFAULT_PORT = 32400 DEFAULT_SSL = False diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index d32abf86ddb6..650ed2c89b08 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -41,6 +41,7 @@ from .const import ( PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, SERVERS, + TRANSIENT_DEVICE_MODELS, ) from .media_browser import browse_media @@ -544,6 +545,15 @@ class PlexMediaPlayer(MediaPlayerEntity): if self.machine_identifier is None: return None + if self.device_product in TRANSIENT_DEVICE_MODELS: + return { + "identifiers": {(PLEX_DOMAIN, "plex.tv-clients")}, + "name": "Plex Client Service", + "manufacturer": "Plex", + "model": "Plex Clients", + "entry_type": "service", + } + return { "identifiers": {(PLEX_DOMAIN, self.machine_identifier)}, "manufacturer": self.device_platform or "Plex", diff --git a/tests/components/plex/test_device_handling.py b/tests/components/plex/test_device_handling.py new file mode 100644 index 000000000000..f36e5dc36417 --- /dev/null +++ b/tests/components/plex/test_device_handling.py @@ -0,0 +1,137 @@ +"""Tests for handling the device registry.""" + +from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN +from homeassistant.components.plex.const import DOMAIN + + +async def test_cleanup_orphaned_devices(hass, entry, setup_plex_server): + """Test cleaning up orphaned devices on startup.""" + test_device_id = {(DOMAIN, "temporary_device_123")} + + device_registry = await hass.helpers.device_registry.async_get_registry() + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + test_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers=test_device_id, + ) + assert test_device is not None + + test_entity = entity_registry.async_get_or_create( + MP_DOMAIN, DOMAIN, "entity_unique_id_123", device_id=test_device.id + ) + assert test_entity is not None + + # Ensure device is not removed with an entity + await setup_plex_server() + device = device_registry.async_get_device(identifiers=test_device_id) + assert device is not None + + await hass.config_entries.async_unload(entry.entry_id) + + # Ensure device is removed without an entity + entity_registry.async_remove(test_entity.entity_id) + await setup_plex_server() + device = device_registry.async_get_device(identifiers=test_device_id) + assert device is None + + +async def test_migrate_transient_devices( + hass, entry, setup_plex_server, requests_mock, player_plexweb_resources +): + """Test cleaning up transient devices on startup.""" + plexweb_device_id = {(DOMAIN, "plexweb_id")} + non_plexweb_device_id = {(DOMAIN, "1234567890123456-com-plexapp-android")} + plex_client_service_device_id = {(DOMAIN, "plex.tv-clients")} + + device_registry = await hass.helpers.device_registry.async_get_registry() + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + # Pre-create devices and entities to test device migration + plexweb_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers=plexweb_device_id, + model="Plex Web", + ) + # plexweb_entity = entity_registry.async_get_or_create(MP_DOMAIN, DOMAIN, "unique_id_123:plexweb_id", suggested_object_id="plex_plex_web_chrome", device_id=plexweb_device.id) + entity_registry.async_get_or_create( + MP_DOMAIN, + DOMAIN, + "unique_id_123:plexweb_id", + suggested_object_id="plex_plex_web_chrome", + device_id=plexweb_device.id, + ) + + non_plexweb_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers=non_plexweb_device_id, + model="Plex for Android (TV)", + ) + entity_registry.async_get_or_create( + MP_DOMAIN, + DOMAIN, + "unique_id_123:1234567890123456-com-plexapp-android", + suggested_object_id="plex_plex_for_android_tv_shield_android_tv", + device_id=non_plexweb_device.id, + ) + + # Ensure the Plex Web client is available + requests_mock.get("/resources", text=player_plexweb_resources) + + plexweb_device = device_registry.async_get_device(identifiers=plexweb_device_id) + non_plexweb_device = device_registry.async_get_device( + identifiers=non_plexweb_device_id + ) + plex_service_device = device_registry.async_get_device( + identifiers=plex_client_service_device_id + ) + + assert ( + len( + hass.helpers.entity_registry.async_entries_for_device( + entity_registry, device_id=plexweb_device.id + ) + ) + == 1 + ) + assert ( + len( + hass.helpers.entity_registry.async_entries_for_device( + entity_registry, device_id=non_plexweb_device.id + ) + ) + == 1 + ) + assert plex_service_device is None + + # Ensure Plex Web entity is migrated to a service + await setup_plex_server() + + plex_service_device = device_registry.async_get_device( + identifiers=plex_client_service_device_id + ) + + assert ( + len( + hass.helpers.entity_registry.async_entries_for_device( + entity_registry, device_id=plexweb_device.id + ) + ) + == 0 + ) + assert ( + len( + hass.helpers.entity_registry.async_entries_for_device( + entity_registry, device_id=non_plexweb_device.id + ) + ) + == 1 + ) + assert ( + len( + hass.helpers.entity_registry.async_entries_for_device( + entity_registry, device_id=plex_service_device.id + ) + ) + == 1 + )