From 0213f43f10157c54379d88eb102951e1f21a5a5b Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 18 Feb 2020 18:46:45 -0600 Subject: [PATCH] Add options to ignore shared/managed Plex clients (#31738) * Add option to ignore shared/managed Plex clients * Start to allow user selection * hass object not ready during init * Don't bother sorting * Plex account multi-select, handle new users not matching config * Fix/add tests * Lint simplifications * Review cleanup * Oops * Rename options attributes, fix/add tests --- homeassistant/components/plex/__init__.py | 2 + homeassistant/components/plex/config_flow.py | 48 ++++++- homeassistant/components/plex/const.py | 2 + homeassistant/components/plex/media_player.py | 4 +- homeassistant/components/plex/server.py | 57 ++++++++- homeassistant/components/plex/strings.json | 4 +- tests/components/plex/mock_classes.py | 51 +++++++- tests/components/plex/test_config_flow.py | 118 +++++++++++++++++- 8 files changed, 272 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 89659769192..0f1873fc86f 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -27,6 +27,7 @@ from homeassistant.helpers.dispatcher import ( ) from .const import ( + CONF_IGNORE_NEW_SHARED_USERS, CONF_SERVER, CONF_SERVER_IDENTIFIER, CONF_SHOW_ALL_CONTROLS, @@ -50,6 +51,7 @@ MEDIA_PLAYER_SCHEMA = vol.Schema( { vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean, vol.Optional(CONF_SHOW_ALL_CONTROLS, default=False): cv.boolean, + vol.Optional(CONF_IGNORE_NEW_SHARED_USERS, default=False): cv.boolean, } ) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 0cbdd4679a9..19cec6dfb8b 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -14,12 +14,15 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.const import CONF_SSL, CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json from .const import ( # pylint: disable=unused-import AUTH_CALLBACK_NAME, AUTH_CALLBACK_PATH, CONF_CLIENT_IDENTIFIER, + CONF_IGNORE_NEW_SHARED_USERS, + CONF_MONITORED_USERS, CONF_SERVER, CONF_SERVER_IDENTIFIER, CONF_SHOW_ALL_CONTROLS, @@ -28,6 +31,7 @@ from .const import ( # pylint: disable=unused-import DOMAIN, PLEX_CONFIG_FILE, PLEX_SERVER_CONFIG, + SERVERS, X_PLEX_DEVICE_NAME, X_PLEX_PLATFORM, X_PLEX_PRODUCT, @@ -254,6 +258,7 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry): """Initialize Plex options flow.""" self.options = copy.deepcopy(config_entry.options) + self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] async def async_step_init(self, user_input=None): """Manage the Plex options.""" @@ -261,6 +266,8 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_plex_mp_settings(self, user_input=None): """Manage the Plex media_player options.""" + plex_server = self.hass.data[DOMAIN][SERVERS][self.server_id] + if user_input is not None: self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] = user_input[ CONF_USE_EPISODE_ART @@ -268,19 +275,56 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS] = user_input[ CONF_SHOW_ALL_CONTROLS ] + self.options[MP_DOMAIN][CONF_IGNORE_NEW_SHARED_USERS] = user_input[ + CONF_IGNORE_NEW_SHARED_USERS + ] + + account_data = { + user: {"enabled": bool(user in user_input[CONF_MONITORED_USERS])} + for user in plex_server.accounts + } + + self.options[MP_DOMAIN][CONF_MONITORED_USERS] = account_data + return self.async_create_entry(title="", data=self.options) + available_accounts = {name: name for name in plex_server.accounts} + available_accounts[plex_server.owner] += " [Owner]" + + default_accounts = plex_server.accounts + known_accounts = set(plex_server.option_monitored_users) + if known_accounts: + default_accounts = { + user + for user in plex_server.option_monitored_users + if plex_server.option_monitored_users[user]["enabled"] + } + for user in plex_server.accounts: + if user not in known_accounts: + available_accounts[user] += " [New]" + + if not plex_server.option_ignore_new_shared_users: + for new_user in plex_server.accounts - known_accounts: + default_accounts.add(new_user) + return self.async_show_form( step_id="plex_mp_settings", data_schema=vol.Schema( { vol.Required( CONF_USE_EPISODE_ART, - default=self.options[MP_DOMAIN][CONF_USE_EPISODE_ART], + default=plex_server.option_use_episode_art, ): bool, vol.Required( CONF_SHOW_ALL_CONTROLS, - default=self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS], + default=plex_server.option_show_all_controls, + ): bool, + vol.Optional( + CONF_MONITORED_USERS, default=default_accounts + ): cv.multi_select(available_accounts), + vol.Required( + CONF_IGNORE_NEW_SHARED_USERS, + default=plex_server.option_ignore_new_shared_users, ): bool, } ), diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 635c981b531..7d6812674ca 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -29,6 +29,8 @@ CONF_SERVER = "server" CONF_SERVER_IDENTIFIER = "server_id" CONF_USE_EPISODE_ART = "use_episode_art" CONF_SHOW_ALL_CONTROLS = "show_all_controls" +CONF_IGNORE_NEW_SHARED_USERS = "ignore_new_shared_users" +CONF_MONITORED_USERS = "monitored_users" AUTH_CALLBACK_PATH = "/auth/plex/callback" AUTH_CALLBACK_NAME = "auth:plex:callback" diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index cd94bb49632..03681e8b677 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -274,7 +274,7 @@ class PlexMediaPlayer(MediaPlayerDevice): thumb_url = self.session.thumbUrl if ( self.media_content_type is MEDIA_TYPE_TVSHOW - and not self.plex_server.use_episode_art + and not self.plex_server.option_use_episode_art ): thumb_url = self.session.url(self.session.grandparentThumb) @@ -481,7 +481,7 @@ class PlexMediaPlayer(MediaPlayerDevice): def supported_features(self): """Flag media player features that are supported.""" # force show all controls - if self.plex_server.show_all_controls: + if self.plex_server.option_show_all_controls: return ( SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index fe453ef2e9e..5532362b87a 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -13,6 +13,8 @@ from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( CONF_CLIENT_IDENTIFIER, + CONF_IGNORE_NEW_SHARED_USERS, + CONF_MONITORED_USERS, CONF_SERVER, CONF_SHOW_ALL_CONTROLS, CONF_USE_EPISODE_ART, @@ -51,6 +53,7 @@ class PlexServer: self._verify_ssl = server_config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) self.options = options self.server_choice = None + self._accounts = [] self._owner_username = None self._version = None @@ -95,6 +98,12 @@ class PlexServer: else: _connect_with_token() + self._accounts = [ + account.name + for account in self._plex_server.systemAccounts() + if account.name + ] + owner_account = [ account.name for account in self._plex_server.systemAccounts() @@ -121,8 +130,22 @@ class PlexServer: _LOGGER.debug("Updating devices") available_clients = {} + ignored_clients = set() new_clients = set() + monitored_users = self.accounts + known_accounts = set(self.option_monitored_users) + if known_accounts: + monitored_users = { + user + for user in self.option_monitored_users + if self.option_monitored_users[user]["enabled"] + } + + if not self.option_ignore_new_shared_users: + for new_user in self.accounts - known_accounts: + monitored_users.add(new_user) + try: devices = self._plex_server.clients() sessions = self._plex_server.sessions() @@ -147,7 +170,12 @@ class PlexServer: if session.TYPE == "photo": _LOGGER.debug("Photo session detected, skipping: %s", session) continue + session_username = session.usernames[0] for player in session.players: + if session_username not in monitored_users: + ignored_clients.add(player.machineIdentifier) + _LOGGER.debug("Ignoring Plex client owned by %s", session_username) + continue self._known_idle.discard(player.machineIdentifier) available_clients.setdefault( player.machineIdentifier, {"device": player} @@ -160,6 +188,8 @@ class PlexServer: new_entity_configs = [] for client_id, client_data in available_clients.items(): + if client_id in ignored_clients: + continue if client_id in new_clients: new_entity_configs.append(client_data) else: @@ -167,11 +197,11 @@ class PlexServer: client_id, client_data["device"], client_data.get("session") ) - self._known_clients.update(new_clients) + self._known_clients.update(new_clients | ignored_clients) - idle_clients = (self._known_clients - self._known_idle).difference( - available_clients - ) + idle_clients = ( + self._known_clients - self._known_idle - ignored_clients + ).difference(available_clients) for client_id in idle_clients: self.refresh_entity(client_id, None, None) self._known_idle.add(client_id) @@ -194,6 +224,11 @@ class PlexServer: """Return the plexapi PlexServer instance.""" return self._plex_server + @property + def accounts(self): + """Return accounts associated with the Plex server.""" + return set(self._accounts) + @property def owner(self): """Return the Plex server owner username.""" @@ -220,15 +255,25 @@ class PlexServer: return self._plex_server._baseurl # pylint: disable=protected-access @property - def use_episode_art(self): + def option_ignore_new_shared_users(self): + """Return ignore_new_shared_users option.""" + return self.options[MP_DOMAIN].get(CONF_IGNORE_NEW_SHARED_USERS, False) + + @property + def option_use_episode_art(self): """Return use_episode_art option.""" return self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] @property - def show_all_controls(self): + def option_show_all_controls(self): """Return show_all_controls option.""" return self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS] + @property + def option_monitored_users(self): + """Return dict of monitored users option.""" + return self.options[MP_DOMAIN].get(CONF_MONITORED_USERS, {}) + @property def library(self): """Return library attribute from server object.""" diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 39abbcf9c6f..1f99e28df8b 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -36,7 +36,9 @@ "description": "Options for Plex Media Players", "data": { "use_episode_art": "Use episode art", - "show_all_controls": "Show all controls" + "show_all_controls": "Show all controls", + "ignore_new_shared_users": "Ignore new managed/shared users", + "monitored_users": "Monitored users" } } } diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py index c4fccb35bb0..6e61dfac3ab 100644 --- a/tests/components/plex/mock_classes.py +++ b/tests/components/plex/mock_classes.py @@ -1,4 +1,6 @@ """Mock classes used in tests.""" +import itertools + from homeassistant.components.plex.const import CONF_SERVER, CONF_SERVER_IDENTIFIER from homeassistant.const import CONF_HOST, CONF_PORT @@ -17,6 +19,12 @@ MOCK_SERVERS = [ }, ] +MOCK_MONITORED_USERS = { + "a": {"enabled": True}, + "b": {"enabled": False}, + "c": {"enabled": True}, +} + class MockResource: """Mock a PlexAccount resource.""" @@ -65,7 +73,14 @@ class MockPlexSystemAccount: class MockPlexServer: """Mock a PlexServer instance.""" - def __init__(self, index=0, ssl=True): + def __init__( + self, + index=0, + ssl=True, + load_users=True, + num_users=len(MOCK_MONITORED_USERS), + ignore_new_users=False, + ): """Initialize the object.""" host = MOCK_SERVERS[index][CONF_HOST] port = MOCK_SERVERS[index][CONF_PORT] @@ -78,11 +93,24 @@ class MockPlexServer: prefix = "https" if ssl else "http" self._baseurl = f"{prefix}://{host}:{port}" self._systemAccount = MockPlexSystemAccount() + self._ignore_new_users = ignore_new_users + self._load_users = load_users + self._num_users = num_users def systemAccounts(self): """Mock the systemAccounts lookup method.""" return [self._systemAccount] + @property + def accounts(self): + """Mock the accounts property.""" + return set(["a", "b", "c"]) + + @property + def owner(self): + """Mock the owner property.""" + return "a" + @property def url_in_use(self): """Return URL used by PlexServer.""" @@ -92,3 +120,24 @@ class MockPlexServer: def version(self): """Mock version of PlexServer.""" return "1.0" + + @property + def option_monitored_users(self): + """Mock loaded config option for monitored users.""" + userdict = dict(itertools.islice(MOCK_MONITORED_USERS.items(), self._num_users)) + return userdict if self._load_users else {} + + @property + def option_ignore_new_shared_users(self): + """Mock loaded config option for ignoring new users.""" + return self._ignore_new_users + + @property + def option_show_all_controls(self): + """Mock loaded config option for showing all controls.""" + return False + + @property + def option_use_episode_art(self): + """Mock loaded config option for using episode art.""" + return False diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 8f9342c4f72..b331444123a 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for Plex config flow.""" +import copy from unittest.mock import patch import asynctest @@ -26,6 +27,7 @@ DEFAULT_OPTIONS = { config_flow.MP_DOMAIN: { config_flow.CONF_USE_EPISODE_ART: False, config_flow.CONF_SHOW_ALL_CONTROLS: False, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: False, } } @@ -457,9 +459,20 @@ async def test_all_available_servers_configured(hass): async def test_option_flow(hass): - """Test config flow selection of one of two bridges.""" + """Test config options flow selection.""" - entry = MockConfigEntry(domain=config_flow.DOMAIN, data={}, options=DEFAULT_OPTIONS) + mock_plex_server = MockPlexServer(load_users=False) + + MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER] + hass.data[config_flow.DOMAIN] = { + config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server} + } + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID}, + options=DEFAULT_OPTIONS, + ) entry.add_to_hass(hass) result = await hass.config_entries.options.async_init( @@ -473,6 +486,8 @@ async def test_option_flow(hass): user_input={ config_flow.CONF_USE_EPISODE_ART: True, config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), }, ) assert result["type"] == "create_entry" @@ -480,6 +495,105 @@ async def test_option_flow(hass): config_flow.MP_DOMAIN: { config_flow.CONF_USE_EPISODE_ART: True, config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: { + user: {"enabled": True} for user in mock_plex_server.accounts + }, + } + } + + +async def test_option_flow_loading_saved_users(hass): + """Test config options flow selection when loading existing user config.""" + + mock_plex_server = MockPlexServer(load_users=True) + + MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER] + hass.data[config_flow.DOMAIN] = { + config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server} + } + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID}, + options=DEFAULT_OPTIONS, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": "test"}, data=None + ) + assert result["type"] == "form" + assert result["step_id"] == "plex_mp_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_USE_EPISODE_ART: True, + config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), + }, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + config_flow.MP_DOMAIN: { + config_flow.CONF_USE_EPISODE_ART: True, + config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: { + user: {"enabled": True} for user in mock_plex_server.accounts + }, + } + } + + +async def test_option_flow_new_users_available(hass): + """Test config options flow selection when new Plex accounts available.""" + + mock_plex_server = MockPlexServer(load_users=True, num_users=2) + + MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER] + hass.data[config_flow.DOMAIN] = { + config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server} + } + + OPTIONS_WITH_USERS = copy.deepcopy(DEFAULT_OPTIONS) + OPTIONS_WITH_USERS[config_flow.MP_DOMAIN][config_flow.CONF_MONITORED_USERS] = { + "a": {"enabled": True} + } + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID}, + options=OPTIONS_WITH_USERS, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": "test"}, data=None + ) + assert result["type"] == "form" + assert result["step_id"] == "plex_mp_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_USE_EPISODE_ART: True, + config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), + }, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + config_flow.MP_DOMAIN: { + config_flow.CONF_USE_EPISODE_ART: True, + config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: { + user: {"enabled": True} for user in mock_plex_server.accounts + }, } }