diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 9d9b8ed8915..8fd666eee77 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -9,6 +9,8 @@ DEFAULT_PORT = 32400 DEFAULT_SSL = False DEFAULT_VERIFY_SSL = True +PLEXTV_THROTTLE = 60 + DEBOUNCE_TIMEOUT = 1 DISPATCHERS = "dispatchers" PLATFORMS = frozenset(["media_player", "sensor"]) diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index dda4c0a46b5..4cee49c3c33 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -1,6 +1,7 @@ """Shared class to maintain Plex server instances.""" import logging import ssl +import time from urllib.parse import urlparse from plexapi.exceptions import NotFound, Unauthorized @@ -34,6 +35,7 @@ from .const import ( PLEX_NEW_MP_SIGNAL, PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, + PLEXTV_THROTTLE, X_PLEX_DEVICE_NAME, X_PLEX_PLATFORM, X_PLEX_PRODUCT, @@ -70,6 +72,9 @@ class PlexServer: self.server_choice = None self._accounts = [] self._owner_username = None + self._plextv_clients = None + self._plextv_client_timestamp = 0 + self._plextv_device_cache = {} self._version = None self.async_update_platforms = Debouncer( hass, @@ -92,15 +97,28 @@ class PlexServer: self._plex_account = plexapi.myplex.MyPlexAccount(token=self._token) return self._plex_account + def plextv_clients(self): + """Return available clients linked to Plex account.""" + now = time.time() + if now - self._plextv_client_timestamp > PLEXTV_THROTTLE: + self._plextv_client_timestamp = now + resources = self.account.resources() + self._plextv_clients = [ + x for x in resources if "player" in x.provides and x.presence + ] + _LOGGER.debug( + "Current available clients from plex.tv: %s", self._plextv_clients + ) + return self._plextv_clients + def connect(self): """Connect to a Plex server directly, obtaining direct URL if necessary.""" config_entry_update_needed = False def _connect_with_token(): - account = plexapi.myplex.MyPlexAccount(token=self._token) available_servers = [ (x.name, x.clientIdentifier) - for x in account.resources() + for x in self.account.resources() if "server" in x.provides ] @@ -112,7 +130,9 @@ class PlexServer: self.server_choice = ( self._server_name if self._server_name else available_servers[0][0] ) - self._plex_server = account.resource(self.server_choice).connect(timeout=10) + self._plex_server = self.account.resource(self.server_choice).connect( + timeout=10 + ) def _connect_with_url(): session = None @@ -124,13 +144,14 @@ class PlexServer: ) def _update_plexdirect_hostname(): - account = plexapi.myplex.MyPlexAccount(token=self._token) matching_server = [ x.name - for x in account.resources() + for x in self.account.resources() if x.clientIdentifier == self._server_id ][0] - self._plex_server = account.resource(matching_server).connect(timeout=10) + self._plex_server = self.account.resource(matching_server).connect( + timeout=10 + ) if self._url: try: @@ -193,7 +214,11 @@ class PlexServer: def _fetch_platform_data(self): """Fetch all data from the Plex server in a single method.""" - return (self._plex_server.clients(), self._plex_server.sessions()) + return ( + self._plex_server.clients(), + self._plex_server.sessions(), + self.plextv_clients(), + ) async def _async_update_platforms(self): """Update the platform entities.""" @@ -217,7 +242,7 @@ class PlexServer: monitored_users.add(new_user) try: - devices, sessions = await self.hass.async_add_executor_job( + devices, sessions, plextv_clients = await self.hass.async_add_executor_job( self._fetch_platform_data ) except ( @@ -245,10 +270,8 @@ class PlexServer: ) return - if ( - device.machineIdentifier not in self._created_clients - and device.machineIdentifier not in ignored_clients - and device.machineIdentifier not in new_clients + if device.machineIdentifier not in ( + self._created_clients | ignored_clients | new_clients ): new_clients.add(device.machineIdentifier) _LOGGER.debug( @@ -258,6 +281,30 @@ class PlexServer: for device in devices: process_device("device", device) + def connect_to_resource(resource): + """Connect to a plex.tv resource and return a Plex client.""" + client_id = resource.clientIdentifier + if client_id in self._plextv_device_cache: + return self._plextv_device_cache[client_id] + + client = None + try: + client = resource.connect(timeout=3) + _LOGGER.debug("plex.tv resource connection successful: %s", client) + except NotFound: + _LOGGER.error("plex.tv resource connection failed: %s", resource.name) + + self._plextv_device_cache[client_id] = client + return client + + for plextv_client in plextv_clients: + if plextv_client.clientIdentifier not in available_clients: + device = await self.hass.async_add_executor_job( + connect_to_resource, plextv_client + ) + if device: + process_device("resource", device) + for session in sessions: if session.TYPE == "photo": _LOGGER.debug("Photo session detected, skipping: %s", session) @@ -296,6 +343,7 @@ class PlexServer: for client_id in idle_clients: self.async_refresh_entity(client_id, None, None) self._known_idle.add(client_id) + self._plextv_device_cache.pop(client_id, None) if new_entity_configs: async_dispatcher_send( @@ -390,7 +438,7 @@ class PlexServer: key = kwargs["plex_key"] try: return self.fetch_item(key) - except plexapi.exceptions.NotFound: + except NotFound: _LOGGER.error("Media for key %s not found", key) return None diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py index 2d69801e797..5fc8ca8d405 100644 --- a/tests/components/plex/mock_classes.py +++ b/tests/components/plex/mock_classes.py @@ -38,28 +38,37 @@ class MockGDM: class MockResource: """Mock a PlexAccount resource.""" - def __init__(self, index): + def __init__(self, index, kind="server"): """Initialize the object.""" - self.name = MOCK_SERVERS[index][CONF_SERVER] - self.clientIdentifier = MOCK_SERVERS[index][ # pylint: disable=invalid-name - CONF_SERVER_IDENTIFIER - ] - self.provides = ["server"] - self._mock_plex_server = MockPlexServer(index) + if kind == "server": + self.name = MOCK_SERVERS[index][CONF_SERVER] + self.clientIdentifier = MOCK_SERVERS[index][ # pylint: disable=invalid-name + CONF_SERVER_IDENTIFIER + ] + self.provides = ["server"] + self.device = MockPlexServer(index) + else: + self.name = f"plex.tv Resource Player {index+10}" + self.clientIdentifier = f"client-{index+10}" + self.provides = ["player"] + self.device = MockPlexClient(f"http://192.168.0.1{index}:32500", index + 10) + self.presence = index == 0 def connect(self, timeout): """Mock the resource connect method.""" - return self._mock_plex_server + return self.device class MockPlexAccount: """Mock a PlexAccount instance.""" - def __init__(self, servers=1): + def __init__(self, servers=1, players=3): """Initialize the object.""" self._resources = [] for index in range(servers): self._resources.append(MockResource(index)) + for index in range(players): + self._resources.append(MockResource(index, kind="player")) def resource(self, name): """Mock the PlexAccount resource lookup method.""" diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 4ffea576514..f218b4c4d79 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -479,8 +479,9 @@ async def test_option_flow_new_users_available(hass, caplog): server_id = mock_plex_server.machineIdentifier - async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) - await hass.async_block_till_done() + with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()): + async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 461efe9d320..5f626bf6a23 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -254,8 +254,11 @@ async def test_setup_with_photo_session(hass): server_id = mock_plex_server.machineIdentifier - async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) - await hass.async_block_till_done() + with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()): + async_dispatcher_send( + hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id) + ) + await hass.async_block_till_done() media_player = hass.states.get("media_player.plex_product_title") assert media_player.state == "idle" diff --git a/tests/components/plex/test_media_players.py b/tests/components/plex/test_media_players.py new file mode 100644 index 00000000000..0cd76c15ab6 --- /dev/null +++ b/tests/components/plex/test_media_players.py @@ -0,0 +1,91 @@ +"""Tests for Plex media_players.""" +from plexapi.exceptions import NotFound + +from homeassistant.components.plex.const import DOMAIN, SERVERS + +from .const import DEFAULT_DATA, DEFAULT_OPTIONS +from .mock_classes import MockPlexAccount, MockPlexServer + +from tests.async_mock import patch +from tests.common import MockConfigEntry + + +async def test_plex_tv_clients(hass): + """Test getting Plex clients from plex.tv.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEFAULT_DATA, + options=DEFAULT_OPTIONS, + unique_id=DEFAULT_DATA["server_id"], + ) + + mock_plex_server = MockPlexServer(config_entry=entry) + mock_plex_account = MockPlexAccount() + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + server_id = mock_plex_server.machineIdentifier + plex_server = hass.data[DOMAIN][SERVERS][server_id] + + resource = next( + x + for x in mock_plex_account.resources() + if x.name.startswith("plex.tv Resource Player") + ) + with patch( + "plexapi.myplex.MyPlexAccount", return_value=mock_plex_account + ), patch.object(resource, "connect", side_effect=NotFound): + await plex_server._async_update_platforms() + await hass.async_block_till_done() + + media_players_before = len(hass.states.async_entity_ids("media_player")) + + # Ensure one more client is discovered + await hass.config_entries.async_unload(entry.entry_id) + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + plex_server = hass.data[DOMAIN][SERVERS][server_id] + + with patch("plexapi.myplex.MyPlexAccount", return_value=mock_plex_account): + await plex_server._async_update_platforms() + await hass.async_block_till_done() + + media_players_after = len(hass.states.async_entity_ids("media_player")) + assert media_players_after == media_players_before + 1 + + # Ensure only plex.tv resource client is found + await hass.config_entries.async_unload(entry.entry_id) + + mock_plex_server.clear_clients() + mock_plex_server.clear_sessions() + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + plex_server = hass.data[DOMAIN][SERVERS][server_id] + + with patch("plexapi.myplex.MyPlexAccount", return_value=mock_plex_account): + await plex_server._async_update_platforms() + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids("media_player")) == 1 + + # Ensure cache gets called + with patch("plexapi.myplex.MyPlexAccount", return_value=mock_plex_account): + await plex_server._async_update_platforms() + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids("media_player")) == 1 diff --git a/tests/components/plex/test_server.py b/tests/components/plex/test_server.py index 6831b045da6..a42e1aff710 100644 --- a/tests/components/plex/test_server.py +++ b/tests/components/plex/test_server.py @@ -26,6 +26,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DEFAULT_DATA, DEFAULT_OPTIONS from .mock_classes import ( + MockPlexAccount, MockPlexArtist, MockPlexLibrary, MockPlexLibrarySection, @@ -62,8 +63,9 @@ async def test_new_users_available(hass): server_id = mock_plex_server.machineIdentifier - async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) - await hass.async_block_till_done() + with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()): + async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users @@ -101,8 +103,9 @@ async def test_new_ignored_users_available(hass, caplog): server_id = mock_plex_server.machineIdentifier - async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) - await hass.async_block_till_done() + with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()): + async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users @@ -253,8 +256,9 @@ async def test_ignore_plex_web_client(hass): server_id = mock_plex_server.machineIdentifier - async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) - await hass.async_block_till_done() + with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(players=0)): + async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() sensor = hass.states.get("sensor.plex_plex_server_1") assert sensor.state == str(len(mock_plex_server.accounts)) @@ -287,8 +291,9 @@ async def test_media_lookups(hass): loaded_server = hass.data[DOMAIN][SERVERS][server_id] # Plex Key searches - async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) - await hass.async_block_till_done() + with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()): + async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() media_player_id = hass.states.async_entity_ids("media_player")[0] with patch("homeassistant.components.plex.PlexServer.create_playqueue"): assert await hass.services.async_call(