From eeb1bfc6f55bd5877c3bd46f59d82f7e93d693be Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 19 Oct 2019 16:31:15 -0500 Subject: [PATCH] Central update for Plex platforms (#27764) * Update Plex platforms together * Remove unnecessary methods * Overhaul of Plex update logic * Apply suggestions from code review Use set instead of list Co-Authored-By: Martin Hjelmare * Review suggestions and cleanup * Fixes, remove sensor throttle * Guarantee entity name, use common scheme * Keep name stable once set --- homeassistant/components/plex/__init__.py | 23 +- homeassistant/components/plex/config_flow.py | 2 +- homeassistant/components/plex/const.py | 7 +- homeassistant/components/plex/media_player.py | 342 ++++++------------ homeassistant/components/plex/sensor.py | 63 ++-- homeassistant/components/plex/server.py | 81 ++++- 6 files changed, 252 insertions(+), 266 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index ed94b6913bc..b6ed3245115 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -1,5 +1,6 @@ """Support to embed Plex.""" import asyncio +from datetime import timedelta import logging import plexapi.exceptions @@ -17,6 +18,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_interval from .const import ( CONF_USE_EPISODE_ART, @@ -26,6 +28,7 @@ from .const import ( DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, + DISPATCHERS, DOMAIN as PLEX_DOMAIN, PLATFORMS, PLEX_MEDIA_PLAYER_OPTIONS, @@ -64,7 +67,9 @@ _LOGGER = logging.getLogger(__package__) def setup(hass, config): """Set up the Plex component.""" - hass.data.setdefault(PLEX_DOMAIN, {SERVERS: {}, REFRESH_LISTENERS: {}}) + hass.data.setdefault( + PLEX_DOMAIN, {SERVERS: {}, REFRESH_LISTENERS: {}, DISPATCHERS: {}} + ) plex_config = config.get(PLEX_DOMAIN, {}) if plex_config: @@ -104,7 +109,7 @@ async def async_setup_entry(hass, entry): ) hass.config_entries.async_update_entry(entry, options=options) - plex_server = PlexServer(server_config, entry.options) + plex_server = PlexServer(hass, server_config, entry.options) try: await hass.async_add_executor_job(plex_server.connect) except requests.exceptions.ConnectionError as error: @@ -129,7 +134,9 @@ async def async_setup_entry(hass, entry): _LOGGER.debug( "Connected to: %s (%s)", plex_server.friendly_name, plex_server.url_in_use ) - hass.data[PLEX_DOMAIN][SERVERS][plex_server.machine_identifier] = plex_server + server_id = plex_server.machine_identifier + hass.data[PLEX_DOMAIN][SERVERS][server_id] = plex_server + hass.data[PLEX_DOMAIN][DISPATCHERS][server_id] = [] for platform in PLATFORMS: hass.async_create_task( @@ -138,6 +145,10 @@ async def async_setup_entry(hass, entry): entry.add_update_listener(async_options_updated) + hass.data[PLEX_DOMAIN][REFRESH_LISTENERS][server_id] = async_track_time_interval( + hass, lambda now: plex_server.update_platforms(), timedelta(seconds=10) + ) + return True @@ -146,7 +157,11 @@ async def async_unload_entry(hass, entry): server_id = entry.data[CONF_SERVER_IDENTIFIER] cancel = hass.data[PLEX_DOMAIN][REFRESH_LISTENERS].pop(server_id) - await hass.async_add_executor_job(cancel) + cancel() + + dispatchers = hass.data[PLEX_DOMAIN][DISPATCHERS].pop(server_id) + for unsub in dispatchers: + unsub() tasks = [ hass.config_entries.async_forward_entry_unload(entry, platform) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 9e74756977d..a11fb9119a6 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -79,7 +79,7 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} self.current_login = server_config - plex_server = PlexServer(server_config) + plex_server = PlexServer(self.hass, server_config) try: await self.hass.async_add_executor_job(plex_server.connect) diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 0b436c4e208..c576f1d6a59 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -2,12 +2,13 @@ from homeassistant.const import __version__ DOMAIN = "plex" -NAME_FORMAT = "Plex {}" +NAME_FORMAT = "Plex ({})" DEFAULT_PORT = 32400 DEFAULT_SSL = False DEFAULT_VERIFY_SSL = True +DISPATCHERS = "dispatchers" PLATFORMS = ["media_player", "sensor"] REFRESH_LISTENERS = "refresh_listeners" SERVERS = "servers" @@ -16,6 +17,10 @@ PLEX_CONFIG_FILE = "plex.conf" PLEX_MEDIA_PLAYER_OPTIONS = "plex_mp_options" PLEX_SERVER_CONFIG = "server_config" +PLEX_NEW_MP_SIGNAL = "plex_new_mp_signal" +PLEX_UPDATE_MEDIA_PLAYER_SIGNAL = "plex_update_mp_signal.{}" +PLEX_UPDATE_SENSOR_SIGNAL = "plex_update_sensor_signal" + CONF_SERVER = "server" CONF_SERVER_IDENTIFIER = "server_id" CONF_USE_EPISODE_ART = "use_episode_art" diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index a49e4c9c057..4a48950a67c 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -1,5 +1,4 @@ """Support to interface with the Plex API.""" -from datetime import timedelta import json import logging from xml.etree.ElementTree import ParseError @@ -29,14 +28,17 @@ from homeassistant.const import ( STATE_PAUSED, STATE_PLAYING, ) -from homeassistant.helpers.event import track_time_interval +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util from .const import ( CONF_SERVER_IDENTIFIER, + DISPATCHERS, DOMAIN as PLEX_DOMAIN, NAME_FORMAT, - REFRESH_LISTENERS, + PLEX_NEW_MP_SIGNAL, + PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, SERVERS, ) @@ -53,142 +55,53 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Plex media_player from a config entry.""" - - def add_entities(entities, update_before_add=False): - """Sync version of async add entities.""" - hass.add_job(async_add_entities, entities, update_before_add) - - hass.async_add_executor_job(_setup_platform, hass, config_entry, add_entities) - - -def _setup_platform(hass, config_entry, add_entities_callback): - """Set up the Plex media_player platform.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] + + def async_new_media_players(new_entities): + _async_add_entities( + hass, config_entry, async_add_entities, server_id, new_entities + ) + + unsub = async_dispatcher_connect(hass, PLEX_NEW_MP_SIGNAL, async_new_media_players) + hass.data[PLEX_DOMAIN][DISPATCHERS][server_id].append(unsub) + + +@callback +def _async_add_entities( + hass, config_entry, async_add_entities, server_id, new_entities +): + """Set up Plex media_player entities.""" + entities = [] plexserver = hass.data[PLEX_DOMAIN][SERVERS][server_id] - plex_clients = {} - plex_sessions = {} - hass.data[PLEX_DOMAIN][REFRESH_LISTENERS][server_id] = track_time_interval( - hass, lambda now: update_devices(), timedelta(seconds=10) - ) + for entity_params in new_entities: + plex_mp = PlexMediaPlayer(plexserver, **entity_params) + entities.append(plex_mp) - def update_devices(): - """Update the devices objects.""" - try: - devices = plexserver.clients() - except plexapi.exceptions.BadRequest: - _LOGGER.exception("Error listing plex devices") - return - except requests.exceptions.RequestException as ex: - _LOGGER.warning( - "Could not connect to Plex server: %s (%s)", - plexserver.friendly_name, - ex, - ) - return - - new_plex_clients = [] - available_client_ids = [] - for device in devices: - # For now, let's allow all deviceClass types - if device.deviceClass in ["badClient"]: - continue - - available_client_ids.append(device.machineIdentifier) - - if device.machineIdentifier not in plex_clients: - new_client = PlexClient( - plexserver, device, None, plex_sessions, update_devices - ) - plex_clients[device.machineIdentifier] = new_client - _LOGGER.debug("New device: %s", device.machineIdentifier) - new_plex_clients.append(new_client) - else: - _LOGGER.debug("Refreshing device: %s", device.machineIdentifier) - plex_clients[device.machineIdentifier].refresh(device, None) - - # add devices with a session and no client (ex. PlexConnect Apple TV's) - try: - sessions = plexserver.sessions() - except plexapi.exceptions.BadRequest: - _LOGGER.exception("Error listing plex sessions") - return - except requests.exceptions.RequestException as ex: - _LOGGER.warning( - "Could not connect to Plex server: %s (%s)", - plexserver.friendly_name, - ex, - ) - return - - plex_sessions.clear() - for session in sessions: - for player in session.players: - plex_sessions[player.machineIdentifier] = session, player - - for machine_identifier, (session, player) in plex_sessions.items(): - if machine_identifier in available_client_ids: - # Avoid using session if already added as a device. - _LOGGER.debug("Skipping session, device exists: %s", machine_identifier) - continue - - if ( - machine_identifier not in plex_clients - and machine_identifier is not None - ): - new_client = PlexClient( - plexserver, player, session, plex_sessions, update_devices - ) - plex_clients[machine_identifier] = new_client - _LOGGER.debug("New session: %s", machine_identifier) - new_plex_clients.append(new_client) - else: - _LOGGER.debug("Refreshing session: %s", machine_identifier) - plex_clients[machine_identifier].refresh(None, session) - - for client in plex_clients.values(): - # force devices to idle that do not have a valid session - if client.session is None: - client.force_idle() - - client.set_availability( - client.machine_identifier in available_client_ids - or client.machine_identifier in plex_sessions - ) - - if client not in new_plex_clients: - client.schedule_update_ha_state() - - if new_plex_clients: - add_entities_callback(new_plex_clients) + async_add_entities(entities, True) -class PlexClient(MediaPlayerDevice): +class PlexMediaPlayer(MediaPlayerDevice): """Representation of a Plex device.""" - def __init__(self, plex_server, device, session, plex_sessions, update_devices): + def __init__(self, plex_server, device, session=None): """Initialize the Plex device.""" + self.plex_server = plex_server + self.device = device + self.session = session self._app_name = "" - self._device = None self._available = False - self._marked_unavailable = None self._device_protocol_capabilities = None self._is_player_active = False - self._is_player_available = False - self._player = None - self._machine_identifier = None + self._machine_identifier = device.machineIdentifier self._make = "" self._name = None self._player_state = "idle" self._previous_volume_level = 1 # Used in fake muting - self._session = None self._session_type = None self._session_username = None self._state = STATE_IDLE self._volume_level = 1 # since we can't retrieve remotely self._volume_muted = False # since we can't retrieve remotely - self.plex_server = plex_server - self.plex_sessions = plex_sessions - self.update_devices = update_devices # General self._media_content_id = None self._media_content_rating = None @@ -208,7 +121,22 @@ class PlexClient(MediaPlayerDevice): self._media_season = None self._media_series_title = None - self.refresh(device, session) + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + server_id = self.plex_server.machine_identifier + unsub = async_dispatcher_connect( + self.hass, + PLEX_UPDATE_MEDIA_PLAYER_SIGNAL.format(self.unique_id), + self.async_refresh_media_player, + ) + self.hass.data[PLEX_DOMAIN][DISPATCHERS][server_id].append(unsub) + + @callback + def async_refresh_media_player(self, device, session): + """Set instance objects and trigger an entity state update.""" + self.device = device + self.session = session + self.async_schedule_update_ha_state(True) def _clear_media_details(self): """Set all Media Items to None.""" @@ -232,52 +160,46 @@ class PlexClient(MediaPlayerDevice): # Clear library Name self._app_name = "" - def refresh(self, device, session): + def update(self): """Refresh key device data.""" self._clear_media_details() - if session: # Not being triggered by Chrome or FireTablet Plex App - self._session = session - if device: - self._device = device + self._available = self.device or self.session + name_base = None + + if self.device: try: - device_url = self._device.url("/") + device_url = self.device.url("/") except plexapi.exceptions.BadRequest: device_url = "127.0.0.1" if "127.0.0.1" in device_url: - self._device.proxyThroughServer() - self._session = None - self._machine_identifier = self._device.machineIdentifier - self._name = NAME_FORMAT.format(self._device.title or DEVICE_DEFAULT_NAME) - self._device_protocol_capabilities = self._device.protocolCapabilities + self.device.proxyThroughServer() + name_base = self.device.title or self.device.product + self._device_protocol_capabilities = self.device.protocolCapabilities + self._player_state = self.device.state - # set valid session, preferring device session - if self._device.machineIdentifier in self.plex_sessions: - self._session = self.plex_sessions.get( - self._device.machineIdentifier, [None, None] - )[0] - - if self._session: - if ( - self._device is not None - and self._device.machineIdentifier is not None - and self._session.players - ): - self._is_player_available = True - self._player = [ + if not self.session: + self.force_idle() + else: + session_device = next( + ( p - for p in self._session.players - if p.machineIdentifier == self._device.machineIdentifier - ][0] - self._name = NAME_FORMAT.format(self._player.title) - self._player_state = self._player.state - self._session_username = self._session.usernames[0] - self._make = self._player.device + for p in self.session.players + if p.machineIdentifier == self.device.machineIdentifier + ), + None, + ) + if session_device: + self._make = session_device.device or "" + self._player_state = session_device.state + name_base = name_base or session_device.title or session_device.product else: - self._is_player_available = False + _LOGGER.warning("No player associated with active session") + + self._session_username = self.session.usernames[0] # Calculate throttled position for proper progress display. - position = int(self._session.viewOffset / 1000) + position = int(self.session.viewOffset / 1000) now = dt_util.utcnow() if self._media_position is not None: pos_diff = position - self._media_position @@ -289,21 +211,22 @@ class PlexClient(MediaPlayerDevice): self._media_position_updated_at = now self._media_position = position - self._media_content_id = self._session.ratingKey - self._media_content_rating = getattr(self._session, "contentRating", None) + self._media_content_id = self.session.ratingKey + self._media_content_rating = getattr(self.session, "contentRating", None) + self._name = self._name or NAME_FORMAT.format(name_base or DEVICE_DEFAULT_NAME) self._set_player_state() - if self._is_player_active and self._session is not None: - self._session_type = self._session.type - self._media_duration = int(self._session.duration / 1000) + if self._is_player_active and self.session is not None: + self._session_type = self.session.type + self._media_duration = int(self.session.duration / 1000) # title (movie name, tv episode name, music song name) - self._media_title = self._session.title + self._media_title = self.session.title # media type self._set_media_type() self._app_name = ( - self._session.section().title - if self._session.section() is not None + self.session.section().title + if self.session.section() is not None else "" ) self._set_media_image() @@ -311,33 +234,21 @@ class PlexClient(MediaPlayerDevice): self._session_type = None def _set_media_image(self): - thumb_url = self._session.thumbUrl + thumb_url = self.session.thumbUrl if ( self.media_content_type is MEDIA_TYPE_TVSHOW and not self.plex_server.use_episode_art ): - thumb_url = self._session.url(self._session.grandparentThumb) + thumb_url = self.session.url(self.session.grandparentThumb) if thumb_url is None: _LOGGER.debug( - "Using media art because media thumb " "was not found: %s", - self.entity_id, + "Using media art because media thumb was not found: %s", self.name ) - thumb_url = self.session.url(self._session.art) + thumb_url = self.session.url(self.session.art) self._media_image_url = thumb_url - def set_availability(self, available): - """Set the device as available/unavailable noting time.""" - if not available: - self._clear_media_details() - if self._marked_unavailable is None: - self._marked_unavailable = dt_util.utcnow() - else: - self._marked_unavailable = None - - self._available = available - def _set_player_state(self): if self._player_state == "playing": self._is_player_active = True @@ -357,41 +268,41 @@ class PlexClient(MediaPlayerDevice): self._media_content_type = MEDIA_TYPE_TVSHOW # season number (00) - if callable(self._session.season): - self._media_season = str((self._session.season()).index).zfill(2) - elif self._session.parentIndex is not None: - self._media_season = self._session.parentIndex.zfill(2) + if callable(self.session.season): + self._media_season = str((self.session.season()).index).zfill(2) + elif self.session.parentIndex is not None: + self._media_season = self.session.parentIndex.zfill(2) else: self._media_season = None # show name - self._media_series_title = self._session.grandparentTitle + self._media_series_title = self.session.grandparentTitle # episode number (00) - if self._session.index is not None: - self._media_episode = str(self._session.index).zfill(2) + if self.session.index is not None: + self._media_episode = str(self.session.index).zfill(2) elif self._session_type == "movie": self._media_content_type = MEDIA_TYPE_MOVIE - if self._session.year is not None and self._media_title is not None: - self._media_title += " (" + str(self._session.year) + ")" + if self.session.year is not None and self._media_title is not None: + self._media_title += " (" + str(self.session.year) + ")" elif self._session_type == "track": self._media_content_type = MEDIA_TYPE_MUSIC - self._media_album_name = self._session.parentTitle - self._media_album_artist = self._session.grandparentTitle - self._media_track = self._session.index - self._media_artist = self._session.originalTitle + self._media_album_name = self.session.parentTitle + self._media_album_artist = self.session.grandparentTitle + self._media_track = self.session.index + self._media_artist = self.session.originalTitle # use album artist if track artist is missing if self._media_artist is None: _LOGGER.debug( - "Using album artist because track artist " "was not found: %s", - self.entity_id, + "Using album artist because track artist was not found: %s", + self.name, ) self._media_artist = self._media_album_artist def force_idle(self): """Force client to idle.""" self._state = STATE_IDLE - self._session = None + self.session = None self._clear_media_details() @property @@ -402,7 +313,7 @@ class PlexClient(MediaPlayerDevice): @property def unique_id(self): """Return the id of this plex client.""" - return self.machine_identifier + return self._machine_identifier @property def available(self): @@ -414,31 +325,11 @@ class PlexClient(MediaPlayerDevice): """Return the name of the device.""" return self._name - @property - def machine_identifier(self): - """Return the machine identifier of the device.""" - return self._machine_identifier - @property def app_name(self): """Return the library name of playing media.""" return self._app_name - @property - def device(self): - """Return the device, if any.""" - return self._device - - @property - def marked_unavailable(self): - """Return time device was marked unavailable.""" - return self._marked_unavailable - - @property - def session(self): - """Return the session, if any.""" - return self._session - @property def state(self): """Return the state of the device.""" @@ -462,8 +353,7 @@ class PlexClient(MediaPlayerDevice): """Return the content type of current playing media.""" if self._session_type == "clip": _LOGGER.debug( - "Clip content type detected, " "compatibility may vary: %s", - self.entity_id, + "Clip content type detected, compatibility may vary: %s", self.name ) return MEDIA_TYPE_TVSHOW if self._session_type == "episode": @@ -560,8 +450,8 @@ class PlexClient(MediaPlayerDevice): # no mute support if self.make.lower() == "shield android tv": _LOGGER.debug( - "Shield Android TV client detected, disabling mute " "controls: %s", - self.entity_id, + "Shield Android TV client detected, disabling mute controls: %s", + self.name, ) return ( SUPPORT_PAUSE @@ -579,7 +469,7 @@ class PlexClient(MediaPlayerDevice): _LOGGER.debug( "Tivo client detected, only enabling pause, play, " "stop, and off controls: %s", - self.entity_id, + self.name, ) return SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_STOP | SUPPORT_TURN_OFF @@ -603,7 +493,7 @@ class PlexClient(MediaPlayerDevice): if self.device and "playback" in self._device_protocol_capabilities: self.device.setVolume(int(volume * 100), self._active_media_plexapi_type) self._volume_level = volume # store since we can't retrieve - self.update_devices() + self.plex_server.update_platforms() @property def volume_level(self): @@ -642,19 +532,19 @@ class PlexClient(MediaPlayerDevice): """Send play command.""" if self.device and "playback" in self._device_protocol_capabilities: self.device.play(self._active_media_plexapi_type) - self.update_devices() + self.plex_server.update_platforms() def media_pause(self): """Send pause command.""" if self.device and "playback" in self._device_protocol_capabilities: self.device.pause(self._active_media_plexapi_type) - self.update_devices() + self.plex_server.update_platforms() def media_stop(self): """Send stop command.""" if self.device and "playback" in self._device_protocol_capabilities: self.device.stop(self._active_media_plexapi_type) - self.update_devices() + self.plex_server.update_platforms() def turn_off(self): """Turn the client off.""" @@ -665,13 +555,13 @@ class PlexClient(MediaPlayerDevice): """Send next track command.""" if self.device and "playback" in self._device_protocol_capabilities: self.device.skipNext(self._active_media_plexapi_type) - self.update_devices() + self.plex_server.update_platforms() def media_previous_track(self): """Send previous track command.""" if self.device and "playback" in self._device_protocol_capabilities: self.device.skipPrevious(self._active_media_plexapi_type) - self.update_devices() + self.plex_server.update_platforms() def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" @@ -706,7 +596,7 @@ class PlexClient(MediaPlayerDevice): except requests.exceptions.ConnectTimeout: _LOGGER.error("Timed out playing on %s", self.name) - self.update_devices() + self.plex_server.update_platforms() def _get_music_media(self, library_name, src): """Find music media and return a Plex media object.""" diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 7d5b54356a0..3cde2adb8f4 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -1,19 +1,21 @@ """Support for Plex media server monitoring.""" -from datetime import timedelta import logging -import plexapi.exceptions -import requests.exceptions - +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle -from .const import CONF_SERVER_IDENTIFIER, DOMAIN as PLEX_DOMAIN, SERVERS +from .const import ( + CONF_SERVER_IDENTIFIER, + DISPATCHERS, + DOMAIN as PLEX_DOMAIN, + NAME_FORMAT, + PLEX_UPDATE_SENSOR_SIGNAL, + SERVERS, +) _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) - async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Plex sensor platform. @@ -26,8 +28,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Plex sensor from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] - sensor = PlexSensor(hass.data[PLEX_DOMAIN][SERVERS][server_id]) - async_add_entities([sensor], True) + plexserver = hass.data[PLEX_DOMAIN][SERVERS][server_id] + sensor = PlexSensor(plexserver) + async_add_entities([sensor]) class PlexSensor(Entity): @@ -35,12 +38,27 @@ class PlexSensor(Entity): def __init__(self, plex_server): """Initialize the sensor.""" + self.sessions = [] self._state = None self._now_playing = [] self._server = plex_server - self._name = f"Plex ({plex_server.friendly_name})" + self._name = NAME_FORMAT.format(plex_server.friendly_name) self._unique_id = f"sensor-{plex_server.machine_identifier}" + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + server_id = self._server.machine_identifier + unsub = async_dispatcher_connect( + self.hass, PLEX_UPDATE_SENSOR_SIGNAL, self.async_refresh_sensor + ) + self.hass.data[PLEX_DOMAIN][DISPATCHERS][server_id].append(unsub) + + @callback + def async_refresh_sensor(self, sessions): + """Set instance object and trigger an entity state update.""" + self.sessions = sessions + self.async_schedule_update_ha_state(True) + @property def name(self): """Return the name of the sensor.""" @@ -51,6 +69,11 @@ class PlexSensor(Entity): """Return the id of this plex client.""" return self._unique_id + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + @property def state(self): """Return the state of the sensor.""" @@ -66,24 +89,10 @@ class PlexSensor(Entity): """Return the state attributes.""" return {content[0]: content[1] for content in self._now_playing} - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update method for Plex sensor.""" - try: - sessions = self._server.sessions() - except plexapi.exceptions.BadRequest: - _LOGGER.error( - "Error listing current Plex sessions on %s", self._server.friendly_name - ) - return - except requests.exceptions.RequestException as ex: - _LOGGER.warning( - "Temporary error connecting to %s (%s)", self._server.friendly_name, ex - ) - return - now_playing = [] - for sess in sessions: + for sess in self.sessions: user = sess.usernames[0] device = sess.players[0].title now_playing_user = f"{user} - {device}" @@ -120,5 +129,5 @@ class PlexSensor(Entity): now_playing_title += f" ({sess.year})" now_playing.append((now_playing_user, now_playing_title)) - self._state = len(sessions) + self._state = len(self.sessions) self._now_playing = now_playing diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index d9ddc28c89a..128bcdd45c6 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -1,17 +1,24 @@ """Shared class to maintain Plex server instances.""" +import logging + import plexapi.myplex import plexapi.playqueue import plexapi.server from requests import Session +import requests.exceptions from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL +from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( CONF_SERVER, CONF_SHOW_ALL_CONTROLS, CONF_USE_EPISODE_ART, DEFAULT_VERIFY_SSL, + PLEX_NEW_MP_SIGNAL, + PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, + PLEX_UPDATE_SENSOR_SIGNAL, X_PLEX_DEVICE_NAME, X_PLEX_PLATFORM, X_PLEX_PRODUCT, @@ -19,6 +26,8 @@ from .const import ( ) from .errors import NoServersFound, ServerNotSpecified +_LOGGER = logging.getLogger(__name__) + # Set default headers sent by plexapi plexapi.X_PLEX_DEVICE_NAME = X_PLEX_DEVICE_NAME plexapi.X_PLEX_PLATFORM = X_PLEX_PLATFORM @@ -31,9 +40,11 @@ plexapi.server.BASE_HEADERS = plexapi.reset_base_headers() class PlexServer: """Manages a single Plex server connection.""" - def __init__(self, server_config, options=None): + def __init__(self, hass, server_config, options=None): """Initialize a Plex server instance.""" + self._hass = hass self._plex_server = None + self._known_clients = set() self._url = server_config.get(CONF_URL) self._token = server_config.get(CONF_TOKEN) self._server_name = server_config.get(CONF_SERVER) @@ -76,13 +87,69 @@ class PlexServer: else: _connect_with_token() - def clients(self): - """Pass through clients call to plexapi.""" - return self._plex_server.clients() + def refresh_entity(self, machine_identifier, device, session): + """Forward refresh dispatch to media_player.""" + dispatcher_send( + self._hass, + PLEX_UPDATE_MEDIA_PLAYER_SIGNAL.format(machine_identifier), + device, + session, + ) - def sessions(self): - """Pass through sessions call to plexapi.""" - return self._plex_server.sessions() + def update_platforms(self): + """Update the platform entities.""" + available_clients = {} + new_clients = set() + + try: + devices = self._plex_server.clients() + sessions = self._plex_server.sessions() + except plexapi.exceptions.BadRequest: + _LOGGER.exception("Error requesting Plex client data from server") + return + except requests.exceptions.RequestException as ex: + _LOGGER.warning( + "Could not connect to Plex server: %s (%s)", self.friendly_name, ex + ) + return + + for device in devices: + available_clients[device.machineIdentifier] = {"device": device} + + if device.machineIdentifier not in self._known_clients: + new_clients.add(device.machineIdentifier) + _LOGGER.debug("New device: %s", device.machineIdentifier) + + for session in sessions: + for player in session.players: + available_clients.setdefault( + player.machineIdentifier, {"device": player} + ) + available_clients[player.machineIdentifier]["session"] = session + + if player.machineIdentifier not in self._known_clients: + new_clients.add(player.machineIdentifier) + _LOGGER.debug("New session: %s", player.machineIdentifier) + + new_entity_configs = [] + for client_id, client_data in available_clients.items(): + if client_id in new_clients: + new_entity_configs.append(client_data) + else: + self.refresh_entity( + client_id, client_data["device"], client_data.get("session") + ) + + self._known_clients.update(new_clients) + + idle_clients = self._known_clients.difference(available_clients) + for client_id in idle_clients: + self.refresh_entity(client_id, None, None) + + if new_entity_configs: + dispatcher_send(self._hass, PLEX_NEW_MP_SIGNAL, new_entity_configs) + + dispatcher_send(self._hass, PLEX_UPDATE_SENSOR_SIGNAL, sessions) @property def friendly_name(self):