diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index f0052ef8830..306e81be65a 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -1,10 +1,12 @@ """Support to embed Plex.""" import asyncio import functools +from functools import partial import json import logging import plexapi.exceptions +from plexapi.gdm import GDM from plexwebsocket import ( SIGNAL_CONNECTION_STATE, SIGNAL_DATA, @@ -33,6 +35,7 @@ from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -43,6 +46,8 @@ from .const import ( CONF_SERVER_IDENTIFIER, DISPATCHERS, DOMAIN as PLEX_DOMAIN, + GDM_DEBOUNCER, + GDM_SCANNER, PLATFORMS, PLATFORMS_COMPLETED, PLEX_SERVER_CONFIG, @@ -67,6 +72,16 @@ async def async_setup(hass, config): await async_setup_services(hass) + gdm = hass.data[PLEX_DOMAIN][GDM_SCANNER] = GDM() + + hass.data[PLEX_DOMAIN][GDM_DEBOUNCER] = Debouncer( + hass, + _LOGGER, + cooldown=10, + immediate=True, + function=partial(gdm.scan, scan_for_clients=True), + ).async_call + return True @@ -143,10 +158,14 @@ async def async_setup_entry(hass, entry): entry.add_update_listener(async_options_updated) + async def async_update_plex(): + await hass.data[PLEX_DOMAIN][GDM_DEBOUNCER]() + await plex_server.async_update_platforms() + unsub = async_dispatcher_connect( hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id), - plex_server.async_update_platforms, + async_update_plex, ) hass.data[PLEX_DOMAIN][DISPATCHERS].setdefault(server_id, []) hass.data[PLEX_DOMAIN][DISPATCHERS][server_id].append(unsub) diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 9823443b897..44505ddd5db 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -13,6 +13,8 @@ PLEXTV_THROTTLE = 60 DEBOUNCE_TIMEOUT = 1 DISPATCHERS = "dispatchers" +GDM_DEBOUNCER = "gdm_debouncer" +GDM_SCANNER = "gdm_scanner" PLATFORMS = frozenset(["media_player", "sensor"]) PLATFORMS_COMPLETED = "platforms_completed" PLAYER_SOURCE = "player_source" diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index a5ac287328e..11ccac72f0a 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -4,6 +4,7 @@ import ssl import time from urllib.parse import urlparse +from plexapi.client import PlexClient from plexapi.exceptions import BadRequest, NotFound, Unauthorized import plexapi.myplex import plexapi.playqueue @@ -32,6 +33,8 @@ from .const import ( CONF_USE_EPISODE_ART, DEBOUNCE_TIMEOUT, DEFAULT_VERIFY_SSL, + DOMAIN, + GDM_SCANNER, PLAYER_SOURCE, PLEX_NEW_MP_SIGNAL, PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, @@ -84,7 +87,7 @@ class PlexServer: self._owner_username = None self._plextv_clients = None self._plextv_client_timestamp = 0 - self._plextv_device_cache = {} + self._client_device_cache = {} self._use_plex_tv = self._token is not None self._version = None self.async_update_platforms = Debouncer( @@ -289,6 +292,9 @@ class PlexServer: return def process_device(source, device): + if device is None: + return + self._known_idle.discard(device.machineIdentifier) available_clients.setdefault(device.machineIdentifier, {"device": device}) available_clients[device.machineIdentifier].setdefault( @@ -321,13 +327,33 @@ class PlexServer: for device in devices: process_device("PMS", device) + def connect_to_client(source, baseurl, machine_identifier, name="Unknown"): + """Connect to a Plex client and return a PlexClient instance.""" + try: + client = PlexClient( + server=self._plex_server, + baseurl=baseurl, + token=self._plex_server.createToken(), + ) + except requests.exceptions.ConnectionError: + _LOGGER.error( + "Direct client connection failed, will try again: %s (%s)", + name, + baseurl, + ) + except Unauthorized: + _LOGGER.error( + "Direct client connection unauthorized, ignoring: %s (%s)", + name, + baseurl, + ) + self._client_device_cache[machine_identifier] = None + else: + self._client_device_cache[client.machineIdentifier] = client + process_device(source, client) + 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) @@ -335,17 +361,33 @@ class PlexServer: _LOGGER.error("plex.tv resource connection failed: %s", resource.name) else: client.proxyThroughServer(value=False, server=self._plex_server) + self._client_device_cache[client.machineIdentifier] = client + process_device("plex.tv", client) - self._plextv_device_cache[client_id] = client - return client + def connect_new_clients(): + """Create connections to newly discovered clients.""" + for gdm_entry in self.hass.data[DOMAIN][GDM_SCANNER].entries: + machine_identifier = gdm_entry["data"]["Resource-Identifier"] + if machine_identifier in self._client_device_cache: + client = self._client_device_cache[machine_identifier] + if client is not None: + process_device("GDM", client) + elif machine_identifier not in available_clients: + baseurl = ( + f"http://{gdm_entry['from'][0]}:{gdm_entry['data']['Port']}" + ) + name = gdm_entry["data"]["Name"] + connect_to_client("GDM", baseurl, machine_identifier, name) - 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("plex.tv", device) + for plextv_client in plextv_clients: + if plextv_client.clientIdentifier in self._client_device_cache: + client = self._client_device_cache[plextv_client.clientIdentifier] + if client is not None: + process_device("plex.tv", client) + elif plextv_client.clientIdentifier not in available_clients: + connect_to_resource(plextv_client) + + await self.hass.async_add_executor_job(connect_new_clients) for session in sessions: if session.TYPE == "photo": @@ -385,7 +427,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) + self._client_device_cache.pop(client_id, None) if new_entity_configs: async_dispatcher_send( diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 4e59b551574..b3fc235bfc8 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -4,7 +4,7 @@ import pytest from homeassistant.components.plex.const import DOMAIN from .const import DEFAULT_DATA, DEFAULT_OPTIONS -from .mock_classes import MockPlexAccount, MockPlexServer +from .mock_classes import MockGDM, MockPlexAccount, MockPlexServer from tests.async_mock import patch from tests.common import MockConfigEntry @@ -43,8 +43,12 @@ def setup_plex_server(hass, entry, mock_plex_account, mock_websocket): async def _wrapper(**kwargs): """Wrap the fixture to allow passing arguments to the MockPlexServer instance.""" config_entry = kwargs.get("config_entry", entry) + disable_gdm = kwargs.pop("disable_gdm", True) plex_server = MockPlexServer(**kwargs) - with patch("plexapi.server.PlexServer", return_value=plex_server): + with patch("plexapi.server.PlexServer", return_value=plex_server), patch( + "homeassistant.components.plex.GDM", + return_value=MockGDM(disabled=disable_gdm), + ): config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py index e16d5cdc13b..13fe4c4113b 100644 --- a/tests/components/plex/mock_classes.py +++ b/tests/components/plex/mock_classes.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_URL from .const import DEFAULT_DATA, MOCK_SERVERS, MOCK_USERS -GDM_PAYLOAD = [ +GDM_SERVER_PAYLOAD = [ { "data": { "Content-Type": "plex/media-server", @@ -27,17 +27,73 @@ GDM_PAYLOAD = [ } ] +GDM_CLIENT_PAYLOAD = [ + { + "data": { + "Content-Type": "plex/media-player", + "Device-Class": "stb", + "Name": "plexamp", + "Port": "36000", + "Product": "Plexamp", + "Protocol": "plex", + "Protocol-Capabilities": "timeline,playback,playqueues,playqueues-creation", + "Protocol-Version": "1", + "Resource-Identifier": "client-2", + "Version": "1.1.0", + }, + "from": ("1.2.3.10", 32412), + }, + { + "data": { + "Content-Type": "plex/media-player", + "Device-Class": "pc", + "Name": "Chrome", + "Port": "32400", + "Product": "Plex Web", + "Protocol": "plex", + "Protocol-Capabilities": "timeline,playback,navigation,mirror,playqueues", + "Protocol-Version": "3", + "Resource-Identifier": "client-1", + "Version": "4.40.1", + }, + "from": ("1.2.3.4", 32412), + }, + { + "data": { + "Content-Type": "plex/media-player", + "Device-Class": "mobile", + "Name": "SHIELD Android TV", + "Port": "32500", + "Product": "Plex for Android (TV)", + "Protocol": "plex", + "Protocol-Capabilities": "timeline,playback,navigation,mirror,playqueues,provider-playback", + "Protocol-Version": "1", + "Resource-Identifier": "client-999", + "Updated-At": "1597686153", + "Version": "8.5.0.19697", + }, + "from": ("1.2.3.11", 32412), + }, +] + class MockGDM: """Mock a GDM instance.""" - def __init__(self): + def __init__(self, disabled=False): """Initialize the object.""" - self.entries = GDM_PAYLOAD + self.entries = [] + self.disabled = disabled - def scan(self): + def scan(self, scan_for_clients=False): """Mock the scan call.""" - pass + if self.disabled: + return + + if scan_for_clients: + self.entries = GDM_CLIENT_PAYLOAD + else: + self.entries = GDM_SERVER_PAYLOAD class MockResource: @@ -56,7 +112,9 @@ class MockResource: 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.device = MockPlexClient( + baseurl=f"http://192.168.0.1{index}:32500", index=index + 10 + ) self.presence = index == 0 self.publicAddressMatches = True @@ -122,6 +180,7 @@ class MockPlexServer: self._systemAccounts = list(map(MockPlexSystemAccount, range(num_users))) self._clients = [] + self._session = None self._sessions = [] self.set_clients(num_users) self.set_sessions(num_users, session_type) @@ -130,7 +189,9 @@ class MockPlexServer: def set_clients(self, num_clients): """Set up mock PlexClients for this PlexServer.""" - self._clients = [MockPlexClient(self._baseurl, x) for x in range(num_clients)] + self._clients = [ + MockPlexClient(baseurl=self._baseurl, index=x) for x in range(num_clients) + ] def set_sessions(self, num_sessions, session_type): """Set up mock PlexSessions for this PlexServer.""" @@ -151,6 +212,10 @@ class MockPlexServer: """Mock the clients method.""" return self._clients + def createToken(self): + """Mock the createToken method.""" + return "temporary_token" + def sessions(self): """Mock the sessions method.""" return self._sessions @@ -204,10 +269,10 @@ class MockPlexServer: class MockPlexClient: """Mock a PlexClient instance.""" - def __init__(self, url, index=0): + def __init__(self, server=None, baseurl=None, token=None, index=0): """Initialize the object.""" self.machineIdentifier = f"client-{index+1}" - self._baseurl = url + self._baseurl = baseurl self._index = index def url(self, key): diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 476c342f176..a7a2896d307 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -37,7 +37,13 @@ from homeassistant.const import ( from .const import DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN from .helpers import trigger_plex_update -from .mock_classes import MockGDM, MockPlexAccount, MockPlexServer, MockResource +from .mock_classes import ( + MockGDM, + MockPlexAccount, + MockPlexClient, + MockPlexServer, + MockResource, +) from tests.async_mock import patch from tests.common import MockConfigEntry @@ -434,10 +440,11 @@ async def test_option_flow_new_users_available( OPTIONS_OWNER_ONLY[MP_DOMAIN][CONF_MONITORED_USERS] = {"Owner": {"enabled": True}} entry.options = OPTIONS_OWNER_ONLY - mock_plex_server = await setup_plex_server(config_entry=entry) + mock_plex_server = await setup_plex_server(config_entry=entry, disable_gdm=False) - trigger_plex_update(mock_websocket) - await hass.async_block_till_done() + with patch("homeassistant.components.plex.server.PlexClient", new=MockPlexClient): + trigger_plex_update(mock_websocket) + await hass.async_block_till_done() server_id = mock_plex_server.machineIdentifier monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users @@ -640,7 +647,11 @@ async def test_manual_config(hass): with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( "homeassistant.components.plex.PlexWebsocket", autospec=True - ), patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()): + ), patch( + "homeassistant.components.plex.GDM", return_value=MockGDM(disabled=True) + ), patch( + "plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount() + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MANUAL_SERVER ) @@ -674,7 +685,11 @@ async def test_manual_config_with_token(hass): with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()), patch( "plexapi.server.PlexServer", return_value=mock_plex_server - ), patch("homeassistant.components.plex.PlexWebsocket", autospec=True): + ), patch( + "homeassistant.components.plex.GDM", return_value=MockGDM(disabled=True) + ), patch( + "homeassistant.components.plex.PlexWebsocket", autospec=True + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_TOKEN: MOCK_TOKEN} ) diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 3c4f9031fad..13e33791459 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -18,7 +18,7 @@ import homeassistant.util.dt as dt_util from .const import DEFAULT_DATA, DEFAULT_OPTIONS from .helpers import trigger_plex_update -from .mock_classes import MockPlexAccount, MockPlexServer +from .mock_classes import MockGDM, MockPlexAccount, MockPlexServer from tests.async_mock import patch from tests.common import MockConfigEntry, async_fire_time_changed @@ -183,6 +183,8 @@ async def test_bad_token_with_tokenless_server(hass, entry): """Test setup with a bad token and a server with token auth disabled.""" with patch("plexapi.server.PlexServer", return_value=MockPlexServer()), patch( "plexapi.myplex.MyPlexAccount", side_effect=plexapi.exceptions.Unauthorized + ), patch( + "homeassistant.components.plex.GDM", return_value=MockGDM(disabled=True) ), patch( "homeassistant.components.plex.PlexWebsocket", autospec=True ) as mock_websocket: diff --git a/tests/components/plex/test_media_players.py b/tests/components/plex/test_media_players.py index fdacd6051ae..94703d5dfb3 100644 --- a/tests/components/plex/test_media_players.py +++ b/tests/components/plex/test_media_players.py @@ -26,7 +26,7 @@ async def test_plex_tv_clients(hass, entry, mock_plex_account, setup_plex_server # Ensure one more client is discovered await hass.config_entries.async_unload(entry.entry_id) - mock_plex_server = await setup_plex_server(config_entry=entry) + mock_plex_server = await setup_plex_server() plex_server = hass.data[DOMAIN][SERVERS][server_id] await plex_server._async_update_platforms() @@ -38,7 +38,7 @@ async def test_plex_tv_clients(hass, entry, mock_plex_account, setup_plex_server # Ensure only plex.tv resource client is found await hass.config_entries.async_unload(entry.entry_id) - mock_plex_server = await setup_plex_server(config_entry=entry) + mock_plex_server = await setup_plex_server() mock_plex_server.clear_clients() mock_plex_server.clear_sessions() diff --git a/tests/components/plex/test_server.py b/tests/components/plex/test_server.py index b650821b3f2..8ac707c393f 100644 --- a/tests/components/plex/test_server.py +++ b/tests/components/plex/test_server.py @@ -2,7 +2,7 @@ import copy from plexapi.exceptions import BadRequest, NotFound -from requests.exceptions import RequestException +from requests.exceptions import ConnectionError, RequestException from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.media_player.const import ( @@ -28,6 +28,7 @@ from homeassistant.const import ATTR_ENTITY_ID from .const import DEFAULT_DATA, DEFAULT_OPTIONS from .helpers import trigger_plex_update from .mock_classes import ( + MockGDM, MockPlexAccount, MockPlexAlbum, MockPlexArtist, @@ -125,6 +126,24 @@ async def test_network_error_during_refresh( ) +async def test_gdm_client_failure(hass, mock_websocket, setup_plex_server): + """Test connection failure to a GDM discovered client.""" + mock_plex_server = await setup_plex_server(disable_gdm=False) + + with patch( + "homeassistant.components.plex.server.PlexClient", side_effect=ConnectionError + ): + trigger_plex_update(mock_websocket) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.plex_plex_server_1") + assert sensor.state == str(len(mock_plex_server.accounts)) + + with patch.object(mock_plex_server, "clients", side_effect=RequestException): + trigger_plex_update(mock_websocket) + await hass.async_block_till_done() + + async def test_mark_sessions_idle(hass, mock_plex_server, mock_websocket): """Test marking media_players as idle when sessions end.""" server_id = mock_plex_server.machineIdentifier @@ -156,7 +175,7 @@ async def test_ignore_plex_web_client(hass, entry, mock_websocket): with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( "plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(players=0) - ): + ), patch("homeassistant.components.plex.GDM", return_value=MockGDM(disabled=True)): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done()